手机远程控制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], stdinslave_fd, stdoutslave_fd, stderrslave_fd, env{**os.environ, TERM: xterm-256color, PYTHONIOENCODING: utf-8}, close_fdsTrue, ) 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双向连接时校验 tokenterm消息最频繁因为终端输出是一小段一小段到达的手机端要像个终端模拟器一样把它们按顺序追加渲染。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 websocketswatchdog两个依赖。安装命令很简单pip install websockets watchdog没有特殊版本要求。网络方面手机和电脑连同一个路由器最好用 WiFi不要跨网段。电脑上需要拿到局域网 IPWindows 用ipconfigMac 和 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], stdinslave, stdoutslave, stderrslave, env{**os.environ, TERM: xterm-256color}, close_fdsTrue) 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。完整流程是这样电脑上启动桥接服务控制台会显示本地 WebSocket 端口已经监听手机 App 输入电脑的局域网 IP 和端口点击连接App 上显示连接成功在手机输入框里敲一段“帮我生成一张本周销量趋势图”点击发送手机上终端面板开始滚动能看到 Claude Code / Codex 的思考过程等它执行完毕文件栏里自动多出一张 PNG 缩略图点开就是完整图表再输入“启动模拟器并打开首页”模拟器画面区域开始每隔一秒刷新最新截图。这六个步骤走通说明四条链路都正常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 实际用了半个月后的体会这个工具我实际用下来最大的感受并不是“手机控制电脑”这个动作本身多酷而是我终于不用长时间守在电脑前了。它能让人在跑任务的时候腾出手干别的同时心里有数任务没卡住、输出正常、图表已经生成。以前跑长任务每隔几分钟就要回来看一眼现在手机放在旁边就可以了有事看一眼没事不用管。如果让我重新做一遍我会建议先把终端双向交互做扎实再考虑图片和模拟器画面。终端是最核心的通道图片和画面都是锦上添花。对很多人来说看到终端滚动输出本身就是一种安全感。工具虽然简单但真正解决了实际问题这大概是它最值钱的地方。