ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

手机远程控制Claude Code终端:WebSocket+PTY实现双向交互与日志监控

手机远程控制Claude Code终端:WebSocket+PTY实现双向交互与日志监控

这阵子一直在折腾 Claude Code 和 Codex 这类命令行 AI 编程工具,任务一跑起来,人就基本被钉在电脑前。终端窗口会不断滚动输出,偶尔还要在中间等一个人工确认,一旦走开,任务就在那干等。A同学有次在跑模拟项目X的时候遇到一个交互式确认,他去倒杯水的工夫,回来发现任务已经卡了快半小时。手机作为“第二块屏幕”的想法就是从这种瞬间冒出来的。

这个项目做了一个手机 App,让 Android 和 iOS 的设备都能通过局域网连接电脑上正在运行的 Claude Code / Codex。它能干的事主要分成三块:第一时间看到终端日志;在需要输入时直接把指令从手机敲进去;AI 生成的图表、网页截图以及安卓模拟器的运行画面,也能在手机上直接看。适合谁呢?适合那些经常跑长任务的人,跑模型训练、批量生成代码、自动化测试脚本,或者用 AI 在电脑上反复改代码的时候,不用一直守在工位上。

我最初以为做个“通知推送”就够了,做到一半发现完全不是。真正有价值的不是一个推送气泡,而是一个能双向交互的控制台。手机不只是接收信息,还要能把指令回传;不仅要看文字,还要看画面。目标清楚了以后,整个项目的形态就完全变了。

1. 项目整体设计与思路拆解

1.1 为什么做这个功能:被命令行任务“钉”在电脑前的日子

Claude Code 和 Codex 这类工具有一个共同特点:它们在终端里运行时会输出大量过程信息,而且经常需要人做实时确认。比如“是否继续执行”、“是否改写这个文件”、“这里有两个方案选哪个”。人一旦离开,任务就像断了线的风筝,轻则等待,重则超时失败。

我观察了一下身边的人,发现大部分用户都有一个共同习惯:任务跑起来之后,每隔几分钟就会下意识地切回终端看一眼。有人甚至因此没法去做别的事,只能在电脑前刷手机。手机就在手里,电脑就在旁边,为什么不把终端搬到手机里呢?

这个工具的定位不是“远程电脑”,而是一个“近距离的第二控制台”。它解决的是真实场景里最常见的几分钟离开,比如去接杯水、去个洗手间、在沙发上休息一会儿。这些场景下,电脑不能一直盯着,但手机始终在身边。把它做出来之后,我的使用习惯很快变了,电脑不需要一直亮着全屏终端,手机偶尔看一眼就行。

从产品形态上讲,这个 App 最核心的价值是三个:终端日志实时可见、指令可反向输入、作业产物可视化。没有这三个,它就是一个高级通知栏;有了这三个,它才是一个能真正接替现场操作的工具。

1.2 整体架构:手机、电脑、命令行工具三者怎么协作

整个架构可以分成三层看:最左边是手机 App,负责界面展示和指令输入;最右边是电脑上的 Claude Code / Codex 命令行进程,它还是一个普通的终端应用,只是在后台运行;中间是我自己写的一个桥接层,这是整件事的核心。

桥接层做的事,通俗讲就是把命令行程序“包裹”起来。它在电脑上创建一个伪终端,把 Claude Code / Codex 的输入输出接到这个伪终端上,再通过 WebSocket 跟手机 App 保持连接。手机上的终端内容不是模拟出来的,而是桥接层从真实的终端流里截获之后原样转发过来的。手机端发出去的按键内容,也先送到桥接层,桥接层再写入伪终端,最终让命令行进程以为真的有人在键盘上输入。

图表和模拟器画面走的是另一条路。桥接层会偷偷监听工作目录,一旦发现 AI 生成了 PNG、HTML 这类文件,就发送一条通知给手机;手机收到后,直接通过局域网里的静态文件服务把图片拉过去展示。模拟器画面则用 ADB 截屏或者视频流的方式,把屏幕内容定时送到手机。这三条通道互不干扰,但都挂在同一个桥接服务上,所以手机端只需要维护一条 WebSocket 长连接,配合几个 HTTP 请求就够了。

1.3 为什么用 WebSocket + PTY 这套组合

先说 PTY。很多刚入门的同学可能会问,为什么不用subprocess.PIPE直接读 stdout?原因很简单,很多命令行工具在不是“真正的终端”时,会改变自己的行为。要么缓冲区变成全缓冲,输出攒一大坨才吐出来;要么干脆不输出彩色和交互式提示;要么遇到需要输入密码、按 y 确认的时候直接失败。PTY 能伪造一个完整的终端环境,程序该有的交互逻辑、ANSI 颜色、光标移动全都保留,我们读取到的内容也更接近人眼看到的真实情况。

WebSocket 则是为了双向实时性。如果只做“把终端输出转发到手机”,用日志推送、HTTP 轮询也能勉强做到,但体验差很多。终端输出是高频的,每秒几十条消息都很正常,HTTP 每次都要建立请求头,速度慢还费电。WebSocket 是一条双向长连接,服务端可以随时把数据推给手机,客户端也可以随时把输入发给服务端,天然适合“控制台”场景。打个比方,HTTP 轮询像你隔一会儿就跑去门口看看有没有信,WebSocket 像邮递员按门铃直接把信递到你手上。项目做到后面,图片、文件、屏幕帧也都想复用这条通道,这一套方案比临时接别的传输协议顺手得多。

2. 核心细节解析与实操要点

2.1 桥接层如何捕获终端输出又回写输入

这一步是整个项目的地基。用 Python 的话,核心代码很短,却藏着不少细节。

import os import pty import subprocess import asyncio master_fd, slave_fd = pty.openpty() proc = subprocess.Popen( ["claude"], stdin=slave_fd, stdout=slave_fd, stderr=slave_fd, env={**os.environ, "TERM": "xterm-256color", "PYTHONIOENCODING": "utf-8"}, close_fds=True, ) os.close(slave_fd) def handle_term_read(): try: data = os.read(master_fd, 8192) except OSError: return if data: asyncio.create_task(broadcast_term(data.decode("utf-8", "replace")))

pty.openpty()会返回一对描述符,master 端由我们读取,slave 端交给子进程用。重点是把 master 描述符注册到 asyncio 事件循环里,用add_reader处理,不要把读终端的操作放在一个死循环里,否则会和 WebSocket 服务抢着用线程,整个服务变得很卡。

回写输入就更简单了,手机端传来消息后直接往 master 写字节就行:

os.write(master_fd, "y\n".encode("utf-8"))

这句代码解决了一个痛点:Claude Code / Codex 在任务执行到一半,经常会问“是否继续?”这类确认,以前人不在电脑前就只能干等,现在桥接层把按键转发过去,App 端做一个快捷回复按钮,点击就发送y\n,效率高很多。注意输入要带换行符,否则程序会一直等回车。

2.2 消息协议:让终端、图片、画面共用一条通道

桥接服务接的不只是 Claude Code / Codex 一个客户端,未来可能还有多个手机同时看,所以通信要提前定义成一套简单的 JSON 协议。我用的字段很少:

type方向data 说明
term服务端 -> 客户端终端流文本
input客户端 -> 服务端要发送给终端的内容
file_notify服务端 -> 客户端新生成的图表文件相对路径
screen服务端 -> 客户端模拟器截图 frame 的显示信息
auth双向连接时校验 token

term消息最频繁,因为终端输出是一小段一小段到达的,手机端要像个终端模拟器一样把它们按顺序追加渲染。file_notify只发路径,不发文件本体。文件本体通过另一个静态文件服务用 HTTP 获取,这么做是为了不把 WebSocket 通道撑爆。一张截图可能几百 KB,如果都用 base64 塞进 JSON,不仅传输变慢,App 端解码也会卡。先通知再拉取,图片还能利用 HTTP 缓存,第二次打开同一张图基本秒开。

2.3 图表自动推送:监听文件夹变化

Claude Code / Codex 运行过程中会生成各种产物。最常见的是 matplotlib 画出来的趋势图、测试报告 HTML、Playwright 截图,甚至是导出的 CSV。如果光盯着终端,很多结论很难看出来,必须看到图。

我在桥接服务里加了一个文件监听器,用 watchdog 库监控工作目录。一旦发现新的.png、.jpg、.html文件生成,就广播一条file_notify消息。听上去很直接,实际踩坑不少。文件写入不是原子的,如果你在写入刚发生时就去读图,大概率读到半张残图。我的做法是先收到on_created事件后,不立刻通知手机,而是等一下,等到文件大小连续两次采样都不变,再广播。这个过程可以写成小函数,几百毫秒即可。

html类型的文件比较特殊,手机端没法直接渲染,我干脆在 App 里做了个“预览网页”的页面,用 WebView 加载。这样 Claude Code / Codex 生成的 HTML 测试报告也能无缝看,这个功能在项目中期加进来,使用频率意外地高。

2.4 模拟器画面:截图轮询与视频流两种方案

模拟器画面是单独一块。Claude Code / Codex 经常会让安卓模拟器跑起来验证 UI,我在电脑上能看到模拟窗口,但人不在跟前就想在手机上看。

最简单、最不容易出错的方案是截图轮询。安卓模拟器基本都自带 ADB 调试通道,可以用一行命令拿屏幕:

adb exec-out screencap -p > screen.png

桥接服务把这张 PNG 存到共享目录下,再发screen消息给 App。App 每隔一两秒刷新一次。对于大多数“确认界面长什么样”的场景,这个频率完全够了。视频流方案更进阶,可以用 ffmpeg 抓取模拟器屏幕然后编码成 MJPEG,再通过 HTTP 流式输出。延迟能低不少,但占用带宽也大,而且不同操作系统抓屏权限和参数差异很大,我建议第一版先做截图轮询,跑顺了再升级。

无论用哪种方案,手机端界面布局里都预留一个屏幕区域,收到新的 frame 就替换旧的,保证画面不闪烁。图片显示可以用 Flutter 的Image.network加内存缓存,实测在同一个局域网下非常流畅。

3. 实操过程与核心环节实现

3.1 电脑端准备清单

动手之前先把环境准备好。电脑需要能跑 Python 3,并且能启动 Claude Code / Codex 命令,我这边用的是 Python 3.10 +websockets+watchdog两个依赖。安装命令很简单,pip install websockets watchdog,没有特殊版本要求。

网络方面,手机和电脑连同一个路由器,最好用 WiFi,不要跨网段。电脑上需要拿到局域网 IP,Windows 用ipconfig,Mac 和 Linux 用ifconfig或ip addr,记下来,一会儿要填进 App。Windows 用户还要注意防火墙,第一次启动服务时系统会弹“是否允许访问”,选择允许专用网络访问;如果没弹,就去防火墙设置里手动放行 TCP 8765 端口。这一步很多同学会漏,手机连不上八成都是它。

电脑端我建议开两个终端窗口:一个窗口跑 Claude Code / Codex 本身,另一个窗口跑桥接服务。实际不是这样,桥接服务会自己启动子进程,所以只需要一个窗口跑桥接服务,它内部会把 Claude Code / Codex 拉起来。为了调试方便,保持这个窗口在前台,能看到日志。

3.2 桥接服务核心代码落地

桥接服务的完整骨架大致如下,我简化掉一些无关紧要的细节,保证可读性。核心点都在:WebSocket 服务、终端转发、文件订阅。

import asyncio, json, os, pty, subprocess from websockets.server import serve CLIENTS = set() MASTER_FD = None async def broadcast(payload): if CLIENTS: text = json.dumps(payload) await asyncio.wait([ws.send(text) for ws in list(CLIENTS)]) async def ws_handler(ws): CLIENTS.add(ws) try: async for raw in ws: msg = json.loads(raw) if msg["type"] == "input" and MASTER_FD: os.write(MASTER_FD, msg["data"].encode("utf-8")) elif msg["type"] == "auth" and msg.get("token") == TOKEN: await ws.send(json.dumps({"type": "auth_ok"})) finally: CLIENTS.discard(ws) def on_term_read(): data = os.read(MASTER_FD, 8192) if data: asyncio.create_task(broadcast({"type": "term", "data": data.decode("utf-8", "replace")})) async def start(): loop = asyncio.get_event_loop() global MASTER_FD master, slave = pty.openpty() MASTER_FD = master subprocess.Popen( ["claude"], stdin=slave, stdout=slave, stderr=slave, env={**os.environ, "TERM": "xterm-256color"}, close_fds=True) os.close(slave) loop.add_reader(MASTER_FD, on_term_read) async with serve(ws_handler, "0.0.0.0", 8765): await asyncio.Future() asyncio.run(start())

这段代码里loop.add_reader是关键,它把终端可读事件交给事件循环,避免了读终端卡住 WebSocket 的问题。实际项目里的broadcast函数还要判断消息大小,终端输出超过 8KB 时应该分片发,避免 App 端一次性处理大内容卡顿。生产环境下我一般再加一个消息队列,把图片通知、终端流分开处理,这里先从简。

3.3 Flutter 端连接与消息分发

手机 App 我用了 Flutter,跨平台一次写完,Android 和 iOS 都能用。连接、收发、渲染的核心代码并不复杂,但消息分发部分值得先把结构搭好。

import 'package:web_socket_channel/io.dart'; final channel = WebSocketChannel.connect(Uri.parse('ws://$host:8765')); channel.stream.listen((message) { final json = jsonDecode(message); switch (json['type']) { case 'term': _appendTerminal(json['data']); break; case 'file_notify': _handleFileNotify(json['path']); break; case 'screen': _refreshScreen(json['url']); break; } }); void _send(String command) { channel.sink.add(jsonEncode({'type': 'input', 'data': command + '\n'})); }

term消息到来后,我直接追加到一个字符串列表里,再用带ListView的终端面板渲染。注意不用每次插入都 rebuild 整个列表,那样日志一多就会卡。一个很简单的做法是用ListView.builder反向滚动,只维护 1000 条以内的日志,超出就扔掉最早的部分。如果终端内容里带 ANSI 颜色控制符,App 端可以先做个预处理,把颜色码转成文字颜色,这个我在问题章节细讲。

图片显示用Image.network即可,但要注意 HTTP 地址不能写死,因为手机和电脑的 IP 是动态的。我把 host 存到 App 的配置页里,所有网络请求统一从一个ServerConfig对象读取。

3.4 端到端联调:从手机跑通一个模拟任务

代码搭好之后,第一次完整联调很关键。我当时用的场景是让 Claude Code / Codex 生成一份趋势图,顺便在模拟器里跑一遍 UI。完整流程是这样:

  1. 电脑上启动桥接服务,控制台会显示本地 WebSocket 端口已经监听;
  2. 手机 App 输入电脑的局域网 IP 和端口,点击连接,App 上显示连接成功;
  3. 在手机输入框里敲一段“帮我生成一张本周销量趋势图”,点击发送;
  4. 手机上终端面板开始滚动,能看到 Claude Code / Codex 的思考过程;
  5. 等它执行完毕,文件栏里自动多出一张 PNG 缩略图,点开就是完整图表;
  6. 再输入“启动模拟器并打开首页”,模拟器画面区域开始每隔一秒刷新最新截图。

这六个步骤走通,说明四条链路都正常:WebSocket 收发、PTY 输入回写、文件监听推送、ADB 截屏刷新。大多数问题都会在这个过程中暴露出来,下一节我集中说几个最常见的坑。

4. 常见问题与排查技巧实录

4.1 手机显示连接失败

连接失败是出现频率最高的问题。我整理过一张速查表:

现象可能原因处理方式
一直转圈,最后提示超时IP 不对用ipconfig重新确认电脑当前局域网 IP
手机和电脑在同一个 WiFi 但连不上防火墙拦截放行 TCP 8765 端口
服务启动了但手机提示拒绝连接监听地址写错改成0.0.0.0监听,别只监听 127.0.0.1
时好时坏电脑有多个网卡确保手机连的 WiFi 网络和 IP 对应的是同一块网卡

排第一的是 IP 写错。很多电脑同时有有线网卡和无线网卡,ipconfig会显示好几个 IP,手机必须填电脑当前接入的那张网卡地址。这两个条件对不上,问题永远不会消失,所以我在 App 连接失败时会把“当前配置的 host 和端口”显示在错误页上,方便对照。

防火墙这一项最容易让人崩溃,因为电脑本地服务完全正常,但手机就是连不上。Windows 第一次启动服务时弹出的授权窗口里,一定要选择“专用网络”,如果选了“取消”或“公共网络”,之后再去防火墙设置里找到对应项目改回来。

4.2 终端输出乱码和颜色处理

第二类高频问题是输出内容缺字、乱码、或者颜色代码变成一堆[31m之类的字符串。乱码根源在于终端编码和 Python 的默认编码不一致。保险的做法是在启动子进程时显式设置:

env={**os.environ, "PYTHONIOENCODING": "utf-8", "LC_ALL": "C.UTF-8"}

这样 Claude Code / Codex 拿到的环境就统一以 UTF-8 输出,中文不再乱码。ANSI 颜色码是另一回事。手机端没有完整的终端仿真,所以我用了一套轻量方案:先解析常见的 ANSI 颜色码,把它们替换成带颜色标记的文本。具体实现不复杂,正则匹配\x1b[31m这类片段,颜色码对应到 Flutter 的Color值,然后把文本按颜色分段渲染。如果嫌麻烦,第一版可以直接剥掉所有颜色,只保留内容,也不会影响使用。

4.3 图表推送慢或漏掉

文件监听常见的坑有三个。第一个是文件还没写完就被读取,前面说过,用“文件大小连续两次不变”作为成熟的判断条件。第二个是 watchdog 在部分文件系统上会对同一个事件触发多次,导致手机端收到重复通知。解决方案是在桥接层加一个简单去重:记住最近 N 个已发送的文件路径和大小,短时间内重复出现就丢弃。

第三个是路径里的中文或空格。文件名如果叫“测试报告 2026.png”,WebSocket 里发相对路径没问题,但手机端拼成 URL 时必须做Uri.encodeComponent,否则 HTTP 请求会 404。这个坑很小,但一旦遇到会以为服务坏了,排查半天。我现在统一在桥接层发送通知之前就把 URL 编码完成,客户端不做乱七八糟的拼接。

4.4 模拟器画面卡顿与延迟优化

手机上看模拟器画面,最明显的体验问题是刷新率跟不上。截图轮询方案里,如果每隔 0.5 秒抓一次,画面上会感觉明显断断续续;但把频率提高到 0.1 秒,又会占用大量 CPU。我的折中办法是自适应频率:先按 1 秒一次刷新,如果 App 端发现两次截图内容变化很小,就把间隔拉长到 2 秒;如果内容持续变化,就缩短到 0.5 秒。这样既不太卡,也不会让电脑风扇一直狂转。

更流畅的替代还是只在重点场景用:就是把模拟器画面做成 MJPEG 视频流。原理是 ffmpeg 抓取模拟器窗口,编码成 JPEG 帧连续输出。这个方法延迟明显更低,但电脑负载更高,需要你根据自己的硬件条件取舍。建议第一版先跑截图轮询,确认整个链路没问题,再根据实际情况决定要不要升级。

5. 安全设计与使用体会

5.1 安全设计必须做对的事

这个工具本质上是把电脑上的终端能力开放给了手机,使用场景又被限定在局域网内,但仍要做几件基础防护,不能偷懒。第一,连接时校验 token。桥接服务启动时生成一个随机串,手机端连接时必须带上,不正确就断开,否则同一 WiFi 下的其他人扫描端口也能连上来。第二,服务只监听内网地址,默认不要开放到公网。局域网内的临时开放够用,用完直接关掉。第三,静态文件服务不要指定到用户主目录,只把当前工作目录暴露出去,避免 App 端通过路径穿越把电脑上的敏感文件拉走。

这些防护在代码里只需要多几行,但对安全意识的影响很大。别觉得“只是自己家里用就无所谓”,家里智能设备被入侵的新闻不少见,终端控制类工具尤其要注意边界。

5.2 这个方案还能怎么扩展

当前的基础版已经解决了“人不在电脑前也能操作”的问题,但它还能往前走很多步。我后续比较想加的是多设备同时观看。让手机和 iPad 同时连上来,终端输出在所有设备上同步,家里其他人也能帮忙看进度。另一个方向是命令审批流:桥接层允许配置一条“敏感命令白名单”,当 Claude Code / Codex 想执行类似删除文件、全量重建数据库这类高危操作时,强制在手机端弹一个确认按钮,点完才放行。这比单纯回写一个y\n安全得多,也更符合真实团队的协作习惯。

操作记录与回放也是不错的方向。终端输出本身是一段时间序列,如果定时把日志快照和文件列表落盘,之后就能像看录像一样回放一次任务的完整过程。对排查 Claude Code / Codex 为什么某些任务失败很有帮助。我现在会把任务结果自动整理成 HTML 报告,App 上打开直接看,不需要额外装阅读器。

5.3 实际用了半个月后的体会

这个工具我实际用下来,最大的感受并不是“手机控制电脑”这个动作本身多酷,而是我终于不用长时间守在电脑前了。它能让人在跑任务的时候腾出手干别的,同时心里有数:任务没卡住、输出正常、图表已经生成。以前跑长任务,每隔几分钟就要回来看一眼,现在手机放在旁边就可以了,有事看一眼,没事不用管。

如果让我重新做一遍,我会建议先把终端双向交互做扎实,再考虑图片和模拟器画面。终端是最核心的通道,图片和画面都是锦上添花。对很多人来说,看到终端滚动输出本身就是一种安全感。工具虽然简单,但真正解决了实际问题,这大概是它最值钱的地方。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进