1. 手机远程操控 AI Agent 的整体设计思路
1.1 为什么会有这个需求
先说一个我自己的真实场景。我平时主力开发机是一台放在家里的工作站,跑着 Claude Code、Codex、OpenCode 这几个命令行 AI Agent,白天在公司用笔记本,晚上回家才碰得到那台机器。问题就来了:白天写代码的时候突然想让 Agent 帮我重构一个模块,或者跑一个批量任务,人不在机器旁边,怎么办?
传统做法无非几种:远程桌面、SSH 客户端、或者干脆把任务攒到晚上一起跑。远程桌面在手机上操作体验极差,SSH 客户端能敲命令但看不到 Agent 的交互式输出,尤其是 Claude Code 这种带 TUI 界面的工具,在手机终端里基本没法用。这就是"手机远程操控 AI Agent"这个需求最原始的出发点——不是炫技,是真的有场景。
这个方案能解决的核心问题有三个:第一,随时随地触发任务,不用守着电脑;第二,实时查看 Agent 执行状态,包括它调用了哪些工具、输出了什么、有没有卡住;第三,多 Agent 统一管理,Claude Code、Codex、OpenCode 这些工具各有各的交互方式,能不能用一个入口统一调度。
适合谁来参考?如果你已经在本地跑过至少一个命令行 AI Agent,对 Node.js、TypeScript 有基本认知,那这篇内容基本可以照着抄。如果你还没装过 Claude Code 或 Codex,建议先把本地环境跑通再来看远程这块,不然会同时踩两个坑。
1.2 整体架构怎么搭
核心思路其实很朴素:在本地跑一个轻量服务,把 Agent 的输入输出通过 WebSocket 暴露出来,手机端用一个 Web 页面连接这个服务。听起来简单,但中间有几个关键决策点需要想清楚。
第一个决策:Agent 进程怎么管理。Claude Code、Codex、OpenCode 都是交互式 CLI 工具,它们不是设计成被程序调用的。你有两个选择——用child_process.spawn起一个伪终端(pty),或者用它们各自的 SDK。Claude Code 有 TypeScript SDK,Codex 也有对应的接口,OpenCode 相对开放一些。我的建议是优先用 SDK,SDK 覆盖不到的功能再退回 pty。原因很简单,pty 方案要处理 ANSI 转义序列、光标移动、终端尺寸变化,在手机小屏幕上渲染出来是一团乱码,而 SDK 返回的是结构化数据,直接渲染成卡片式 UI 舒服得多。
第二个决策:通信协议。HTTP 轮询太浪费,SSE 只能单向推送,WebSocket 是唯一合理的选择。手机端发指令走 WS,Agent 输出也走 WS 推回来,双向对称,实现起来反而最简单。
第三个决策:安全边界。这个必须重点说。你把一个能执行任意命令的服务暴露到公网,等于把家门钥匙挂在门口。我的做法是只在内网或者通过可信的私有网络访问,绝对不做公网端口映射。如果确实需要外网访问,用一层带认证的反向代理,并且给 Agent 的执行权限做白名单限制。这一点后面会单独展开。
整体数据流是这样的:手机浏览器加载一个静态页面,页面通过 WebSocket 连到本地服务的/ws端点,服务端维护一个 Agent 会话池,每个会话对应一个正在运行的 Agent 进程或 SDK 实例。手机发来的消息经过路由分发到对应会话,Agent 的输出经过格式化后推回手机。
1.3 技术选型背后的取舍
为什么用 TypeScript 而不是 Python?因为 Claude Code 的 SDK 是 TypeScript 优先的,Codex 的生态也是 Node 侧更完整,OpenCode 本身就是 TS 写的。用同一套语言栈,SDK 调用、类型定义、进程管理都在一个工程里,不用跨语言调试。而且 Node 的ws库和node-pty库成熟度很高,踩坑少。
为什么不用现成的 Web 终端方案比如 ttyd 或者 gotty?我试过,它们确实能把终端搬到浏览器,但问题是它们只做终端转发,不理解 Agent 的语义。你看到的还是原始 ANSI 流,手机上要横向滚动才能看清,而且没法做"任务列表""历史记录""一键重跑"这些针对 Agent 的增强功能。自己写一层虽然多花两天,但后面用起来舒服太多。
前端为什么不用 React 或者 Vue 搞一套完整 SPA?因为手机端要的是快。一个单 HTML 文件加原生 JS,加载速度比打包出来的 SPA 快一个数量级,而且不用处理构建工具链。对于这种工具型页面,原生 JS 完全够用,代码量也就几百行。
2. 核心细节解析与实操要点
2.1 Agent 进程的启动与生命周期管理
先说 Claude Code 的接入。它提供了 TypeScript SDK,安装方式是npm install @anthropic-ai/claude-code(具体包名以官方为准,我这里说的是思路)。SDK 的核心是一个query函数,你传入 prompt 和配置,它返回一个异步迭代器,每次迭代产出一条消息。这种流式接口天然适合 WebSocket 推送。
import { query } from "@anthropic-ai/claude-code"; async function runClaude(prompt: string, onMessage: (msg: any) => void) { const response = query({ prompt, options: { cwd: "/path/to/your/project", permissionMode: "acceptEdits", }, }); for await (const message of response) { onMessage(message); } }这里有个关键点:permissionMode的设置。默认情况下 Claude Code 每执行一个工具调用都要问用户确认,在手机上你不可能每次都点确认。所以要么设成acceptEdits自动接受文件编辑,要么设成更宽松的模式。但宽松模式意味着 Agent 可以执行任意命令,风险很高,一定要配合工作目录限制和命令白名单。
Codex 的接入思路类似,它也有对应的 SDK 或者可以通过codex exec这种非交互模式调用。OpenCode 相对特殊,它的免费层有使用限制,只能在 OpenCode 自己的环境里用,如果你要远程调用,需要确认你的套餐支持 API 访问。这一点在热词里也提到了 "opencode's free tier can only be used from within opencode",踩过这个坑的人不少。
进程生命周期管理要注意几个点。第一,会话要有超时,一个 Agent 跑太久没输出,要么是卡住了要么是任务太大,超过阈值应该主动终止并通知手机端。第二,进程要能被强制杀掉,手机端要有一个"停止"按钮,对应服务端的kill操作。第三,输出要缓冲,Agent 可能瞬间吐出大量内容,直接推给手机会卡顿,服务端做一层节流,比如每 100ms 合并一次推送。
2.2 WebSocket 通信层的设计
WebSocket 服务用ws库就够了。核心是维护一个会话映射表:
import { WebSocketServer } from "ws"; interface Session { id: string; agentType: "claude" | "codex" | "opencode"; process?: ChildProcess; buffer: string[]; lastActive: number; } const sessions = new Map<string, Session>(); const wss = new WebSocketServer({ port: 8080, path: "/ws" }); wss.on("connection", (ws) => { ws.on("message", async (raw) => { const msg = JSON.parse(raw.toString()); switch (msg.type) { case "start": // 创建新会话 break; case "input": // 向指定会话发送输入 break; case "stop": // 终止会话 break; } }); });消息协议我建议用简单的 JSON,字段包括type、sessionId、payload。不要过度设计,什么 protobuf、MessagePack 在这个场景下都是过度工程,JSON 的可读性在调试时价值巨大。
有一个容易被忽略的点:断线重连。手机网络切换(WiFi 转 4G)会导致 WebSocket 断开,如果服务端不保留会话状态,重连后你就丢失了所有正在跑的任务。我的做法是服务端会话独立于连接存在,连接断开只是取消订阅,会话继续跑,重连后通过sessionId重新订阅输出。这样即使手机锁屏、切后台,任务也不会中断。
心跳也不能少。手机端每 30 秒发一个 ping,服务端回 pong,超过 90 秒没收到就认为连接死了,清理掉对应的订阅关系。没有心跳的话,很多中间设备会静默断开空闲连接,你以为是连着的结果消息发不出去。
2.3 手机端 UI 的关键设计
手机屏幕小,UI 设计要克制。我的页面结构是这样的:顶部一个会话标签栏,可以横向滑动切换不同 Agent;中间是消息流,Agent 的输出按类型渲染成不同样式——普通文本、代码块、工具调用卡片、错误提示;底部是输入框和发送按钮,外加一个"停止"按钮。
代码块渲染是个重点。Agent 输出的代码要能横向滚动,不能换行,否则缩进全乱。用<pre><code>配合overflow-x: auto就行,不需要引入 highlight.js 这种重库,手机端性能优先。
工具调用卡片是我觉得最有价值的设计。Claude Code 执行任务时会调用 Read、Edit、Bash 等工具,这些调用如果只显示原始 JSON 很难看。我把它渲染成一张小卡片,显示工具名、关键参数、执行结果摘要,点击可以展开看完整内容。这样你一眼就能看出 Agent 在干什么,而不是盯着一堆 JSON 发呆。
输入框要支持多行,因为有时候你要给 Agent 一段比较长的指令。用textarea配合自动高度调整,最多长到屏幕的三分之一就内部滚动。发送按钮要防抖,避免手抖连点导致重复提交。
提示:手机端一定要做"输入草稿"保存。你在输入框里写了一半的指令,切出去接个电话回来发现没了,那种体验非常糟糕。用 localStorage 存一下,成本极低。
3. 实操过程与核心环节实现
3.1 从零搭建服务端的完整步骤
第一步,初始化工程。我习惯用 pnpm,速度快、磁盘占用小。
mkdir agent-remote && cd agent-remote pnpm init pnpm add ws node-pty pnpm add -D typescript @types/node @types/ws tsx第二步,配置 TypeScript。tsconfig.json里关键是"module": "ESNext"和"target": "ES2022",因为要用到顶层 await 和异步迭代器。
{ "compilerOptions": { "target": "ES2022", "module": "ESNext", "moduleResolution": "bundler", "strict": true, "esModuleInterop": true, "outDir": "dist" }, "include": ["src/**/*.ts"] }第三步,写 Agent 适配层。我把它抽象成一个接口,每种 Agent 实现自己的适配器:
interface AgentAdapter { start(prompt: string, cwd: string): AsyncIterable<AgentMessage>; stop(): Promise<void>; } interface AgentMessage { type: "text" | "tool_call" | "tool_result" | "error" | "done"; content: string; meta?: Record<string, unknown>; }Claude 适配器用 SDK,Codex 适配器用它的 exec 模式加 stdout 解析,OpenCode 适配器根据你的套餐情况选择 API 或 CLI。这样上层 WebSocket 服务不用关心底层是哪个 Agent,统一处理AgentMessage流。
第四步,实现会话管理器。核心是三个方法:createSession、sendInput、destroySession。会话对象里存 Agent 适配器实例、输出缓冲、订阅者列表。输出缓冲用环形数组,只保留最近 N 条,避免内存无限增长。
第五步,接 WebSocket。前面已经给了骨架,补充一点:每个连接要绑定一个用户标识,即使是单人使用也要做,因为将来你可能想从多个设备同时连。用连接建立时生成的一个随机 token 作为标识,存在连接对象上。
第六步,加一层静态文件服务。手机端页面就一个 HTML 文件,用 Node 内置的http模块起一个静态服务,和 WebSocket 共用一个端口(通过 upgrade 事件区分)。这样你只需要暴露一个端口,配置简单。
3.2 参数计算与性能调优
并发数怎么定?这取决于你的机器配置和 Agent 类型。Claude Code 每个会话大概占用 100-200MB 内存,Codex 类似,OpenCode 轻一些。一台 16GB 内存的机器,留 4GB 给系统,理论上能跑 50 个以上会话。但实际瓶颈不在内存,在API 速率限制。你同时跑 10 个 Claude 会话,很可能触发上游的并发限制,导致部分请求失败。
我的经验值是同时活跃会话不超过 5 个,超过的排队。排队逻辑很简单,用一个队列存待启动的任务,每完成一个就出队一个。这样既不会触发限流,又能保证任务最终都执行。
输出节流的参数怎么定?我实测下来,100ms 的合并窗口是个甜点。低于 50ms,手机端渲染压力大,尤其是输出大量日志的时候;高于 200ms,交互感明显变差,你打字后要等一会儿才看到回应。100ms 刚好在感知阈值以下,同时把高频小包合并成低频大包,网络开销降低一个数量级。
心跳间隔 30 秒,超时 90 秒,这是经过验证的稳妥值。有些资料建议 15 秒心跳,但在移动网络下过于频繁的心跳反而增加耗电和断连概率。90 秒的超时给了足够的容错空间,即使中间有一次心跳丢失也不会误判。
缓冲区大小我设的是 500 条消息。超过就丢弃最旧的,因为手机端也不太可能往回翻几百条。如果你需要完整历史,应该落盘到 SQLite,而不是全放内存。
3.3 手机端页面的实现细节
页面骨架很朴素:
<!DOCTYPE html> <html> <head> <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1"> <title>Agent Remote</title> <style>/* 样式 */</style> </head> <body> <div id="tabs"></div> <div id="messages"></div> <div id="input-bar"> <textarea id="input"></textarea> <button id="send">发送</button> <button id="stop">停止</button> </div> <script>/* 逻辑 */</script> </body> </html>viewport里的maximum-scale=1很重要,防止 iOS 在输入框聚焦时自动放大页面,那个体验很糟糕。
WebSocket 连接逻辑要处理重连:
let ws; let reconnectDelay = 1000; function connect() { ws = new WebSocket(`ws://${location.host}/ws`); ws.onopen = () => { reconnectDelay = 1000; // 重新订阅已有会话 }; ws.onclose = () => { setTimeout(connect, reconnectDelay); reconnectDelay = Math.min(reconnectDelay * 2, 30000); }; ws.onmessage = (e) => renderMessage(JSON.parse(e.data)); }指数退避重连是标配,初始 1 秒,每次翻倍,上限 30 秒。这样网络恢复后能快速重连,又不会在服务端挂掉时疯狂重试。
消息渲染用DocumentFragment批量插入,避免频繁操作 DOM。每条消息渲染完自动滚到底部,但如果用户手动往上滚了,就不要强制滚动,否则用户想看历史记录时会被不断打断。判断方法是记录当前scrollTop和scrollHeight的关系,接近底部才自动滚。
注意:手机端渲染代码块时,一定要对内容做 HTML 转义。Agent 输出的内容里可能包含
<script>之类的字符串,直接 innerHTML 会有 XSS 风险。虽然是你自己的 Agent,但 Agent 可能读取了不可信的文件内容,安全边界不能省。
4. 常见问题与排查技巧实录
4.1 连接类问题速查
| 现象 | 可能原因 | 排查方法 | 解决方式 |
|---|---|---|---|
| 手机连不上服务 | 不在同一网络 | 手机浏览器访问服务端 IP 的静态页 | 确认 WiFi 一致,或走私有网络 |
| 连上后立即断开 | 端口被占用或路径不对 | 看服务端日志有无 upgrade 请求 | 检查path配置和防火墙 |
| 频繁重连 | 心跳超时或网络抖动 | 抓包看心跳包是否发出 | 调整心跳间隔,检查省电模式 |
| 消息延迟高 | 输出节流窗口过大 | 观察消息时间戳 | 把节流窗口降到 100ms |
连接问题里最坑的是手机省电模式。iOS 和 Android 在锁屏后都会限制后台网络活动,你的 WebSocket 可能被系统挂起。表现就是锁屏几分钟后回来,消息全断了。这个没法完全避免,只能靠重连机制兜底。我的做法是在页面visibilitychange事件里主动检测连接状态,页面重新可见时如果连接已断就立即重连,不等退避计时。
4.2 Agent 执行类问题
Claude Code 报 "your organization has disabled claude subscription access" 这类错误,通常是账号权限问题,和远程方案本身无关,但会让人误以为是服务端配置错了。排查时先确认本地直接跑 Claude Code 是否正常,本地正常再查远程链路。
Codex 接入第三方模型(比如 DeepSeek)时,要注意 base URL 和模型名的配置。Codex 的配置文件里model_provider和model两个字段要匹配,写错了会报 provider 错误。这个在热词里也有体现,说明踩坑的人不少。
OpenCode 的免费层限制是个硬门槛。如果你看到 "opencode's free tier can only be used from within opencode" 这个提示,说明你的调用方式不被免费层允许。要么升级套餐,要么换用其他 Agent。不要试图绕过,浪费时间。
Agent 卡住不动的情况也常见。判断方法是看最后一条输出的时间戳,超过 5 分钟没有任何输出,基本可以判定卡死。这时候服务端应该主动发一个探测,如果 Agent 没响应就标记为异常,通知手机端。手机端给一个"强制重启"按钮,对应服务端的 kill 加重新创建会话。
4.3 我踩过的几个坑
第一个坑:pty 方案的终端尺寸。一开始我用 node-pty 起 Claude Code,结果输出全是乱码。原因是 pty 需要一个终端尺寸,默认是 80x24,但 Claude Code 的 TUI 会根据尺寸调整布局,尺寸不对就渲染错乱。后来改成 SDK 方案才彻底解决。如果你非要用 pty,记得在 spawn 时传cols和rows,并且在手机端旋转屏幕时同步更新。
第二个坑:输出编码。Agent 输出的中文在某些情况下会乱码,尤其是通过 pty 的时候。原因是编码没设对,要确保env里LANG和LC_ALL设成en_US.UTF-8或zh_CN.UTF-8。这个坑排查了很久,因为英文输出正常,只有中文乱,很容易误以为是前端渲染问题。
第三个坑:会话泄漏。早期版本我没有做会话清理,手机端断开后服务端的 Agent 进程还在跑,跑了一天下来机器上堆了几十个僵尸进程,内存直接爆了。后来加了定时清理,每 5 分钟扫一遍,超过 30 分钟无活动的会话自动销毁。这个逻辑一定要有,不然跑久了必出问题。
第四个坑:权限模式太宽松。有一次我把 permissionMode 设成了完全自动,结果 Agent 在执行任务时删掉了一个不该删的目录。幸好是测试环境。从那以后我坚持两个原则:工作目录必须限定在项目目录内,危险命令(rm -rf、git push --force 之类)必须走确认流程,即使是在手机上也要弹一个确认框。
4.4 安全加固的几条硬规矩
远程操控 Agent 本质上是远程执行代码,安全等级要按最高标准来。我的几条硬规矩:
服务只监听内网地址,不做公网映射。如果必须外网访问,前面加一层带强认证的反向代理,并且限制来源 IP。
Agent 的工作目录用白名单,只能访问指定的几个项目目录,不能访问家目录、系统目录。
危险命令拦截。在服务端加一层命令解析,遇到rm -rf /、mkfs、dd这类命令直接拒绝执行并告警。这不是万无一失,但能挡住大部分误操作。
操作日志全量记录。每条指令、每次工具调用、每个执行结果都落盘,出问题能追溯。日志本身也要注意脱敏,别把密钥之类的敏感信息记进去。
提示:如果你只是自己用,最简单的安全方案是只在家里内网用,出门通过可信的私有网络接入。不要图省事把端口开到公网,那是在给自己埋雷。
5. 后续可以怎么扩展
这套东西跑通之后,能扩展的方向不少。我目前在做的一个是任务模板,把常用的指令存成模板,手机上一点就执行,不用每次手打。比如"跑一遍测试并修复失败用例""检查依赖更新"这种,做成按钮很实用。
另一个方向是多设备同步。现在是从手机连家里机器,如果我在公司也想看同一个会话,就需要服务端支持多订阅者。前面提到的连接绑定用户标识就是为这个准备的,改造成本不高。
还有一个我觉得挺有意思的:Agent 之间的协作。让 Claude Code 负责写代码,Codex 负责 review,OpenCode 负责跑测试,三个 Agent 串成一个流水线。这个需要服务端做一层编排,把上一个 Agent 的输出作为下一个的输入。技术上不难,难的是设计好中断和回滚机制,毕竟 Agent 的输出不确定性很高。
最后分享一个小技巧:手机端加一个"语音输入"按钮,调用浏览器的 Web Speech API,把语音转成文字填进输入框。走路的时候想给 Agent 下个指令,说一句话就行,比打字快多了。这个 API 在移动端支持度已经不错,成本几乎为零,值得一试。