把 Codex 的 Computer Use 接进 Claude Code
发布时间:
后续:不怎么用这个了,用 browserskill 感觉效果更好了。
一、 token
| 指标 | 数值 |
|---|---|
| 工具调用 | 202 次 |
| 截图 | 43 张(3840×2100 JPEG,单张约 550KB) |
| 总耗时 | 26 分钟(平均每轮 7.7 秒) |
| 总 token | 28,713,396 |
| 其中 input | 28,690,513 |
| 其中 cached input | 28,001,920(97.6%) |
| output | 22,883 |
| reasoning | 6,061 |
| 单次请求峰值 input | 466,288 |
输入量是输出量的 1250 倍,说明开销不在模型生成了多少内容,而在每一步都要把整段历史、工具定义和之前的返回结果重新发一遍。单算截图,43 张图按每张约 1500 token 计,总共才 6.5 万左右,占总输入 0.2%。就算把截图全部拿掉,这个任务的 token 总量也几乎不会变。真正的乘数是轮数乘以每轮上下文大小:
总消耗 ≈ 轮数 × 每轮上下文大小
202 轮 × 平均 14 万 token ≈ 2800 万
随着任务推进,截图、终端输出和工具结果全都留在历史消息里,下一轮继续原样重发。这是所有长链路 Agent 循环的结构性成本,computer use 只是把轮数和单轮信息量同时推高了。
二、官方 Codex 的 computer use 是怎么实现的
在动手移植之前,先把官方 ChatGPT 桌面端里自带的这套运行时拆开看了一遍。它的调用栈分层如下:
模型
└─ MCP tool: cua_repl.js (整个会话只暴露这一个工具,持久 REPL)
└─ node_repl(常驻 Node 进程)
└─ @oai/cua → @oai/sky
└─ Unix socket + JSON-RPC(4 字节长度前缀,单帧上限 8MB)
~/Library/Group Containers/2DC432GLL2.com.openai.sky.CUAService/IPC/computeruse.sock
└─ SkyComputerUseService(Swift 常驻进程,API 版本 CodexComputerUseIPC-5)
原生 Swift 服务那一侧主要调三样系统接口:用 AXUIElement 和 AXObserver 读取窗口无障碍树并压成带索引的缩进文本(默认只返回 diff),用 ScreenCaptureKit 截取窗口输出 JPEG,用 CGEvent 合成鼠标与键盘事件(粘贴操作走 NSPasteboard 并在执行后恢复原剪贴板)。
以获取窗口画面为例,当模型在常驻 REPL 里执行一行:
var app = await cua.getApp('com.google.Chrome');
底下的调用过程是:
JS 在常驻 REPL 里执行(cua 对象来自 @oai/cua)
→ @oai/sky 的 MacNativePipeTransport 把调用序列化成 JSON-RPC
→ 写进 Unix socket:4 字节小端长度前缀 + UTF-8 JSON(单帧上限 8MB)
~/Library/Group Containers/2DC432GLL2.com.openai.sky.CUAService/IPC/computeruse.sock
→ SkyComputerUseService 收到 ComputerUseIPCAppGetSkyshotRequest(API 版本 CodexComputerUseIPC-5)
├─ ScreenCaptureKit 截这个窗口 → JPEG
└─ AXUIElement 遍历无障碍树 → 带索引的缩进文本
→ 回包:{ text, screenshot: "data:image/jpeg;base64,..." }
→ JS 侧解出字节,调 nodeRepl.emitImage()
→ 工具结果里多出一个 image block
中间没有单独的 OCR 引擎或元素检测小模型,窗口状态文本和 3840×2100 的 JPEG 截图直接进入模型上下文,由多模态模型自己看图。
遇到画布(Canvas)里没有任何可定位 DOM 元素的情况,模型就只能对着那张 3840×2100 的截图估算像素坐标,再调 await app.click([2150, 690])。调用经由 JSON-RPC 传给 Swift 服务:
cua.click → JSON-RPC
{ action: { click: { at: { coordinate: [2150, 690] }, clickCount: 1, mouseButton: 0 } } }
→ Swift 服务把窗口坐标换算成屏幕坐标
→ CGEvent 在该点合成 mouseDown / mouseUp
→ 事件落到 Chromium → 页面 → noVNC 的 canvas
→ noVNC 编码成 RFB PointerEvent,经 WebSocket 发给远端 VM
因为像素坐标是模型看图估出来的,存在偏差可能,所以点完通常还得再截一张图确认焦点是否到位。
键盘输入则分两条通道,取决于操作对象是桌面窗口还是浏览器标签页:
| 目标 | 通道 | 末端 |
|---|---|---|
桌面窗口(LCU 的 app.typeText) | Unix socket → Swift → CGEvent 键盘事件 | 落到当前焦点应用 |
浏览器标签(tab.typeText) | CDP Input.dispatchKeyEvent | 页面 JS → noVNC 编码成 RFB → WebSocket → 远端 |
这两条通道都是直接往事件队列里塞按键调用,不受输入法和人手按键延迟限制,单次 typeText(c) 往返约 5 毫秒,实测 422 个字符 1.23 秒就能发完(约 340 字符/秒)。之所以在代码里还必须用 await 逐字符发送,是因为 noVNC 的 Canvas 不是普通输入框,调 Input.insertText 整段插入不会触发按键事件,远端什么都收不到;而如果一次性并发塞几百个 keydown,又会冲垮 noVNC 的 RFB 解码队列导致丢字。另外,单次输入虽然只要几十毫秒,但算上每步前后的截图(1 到 6 秒)、模型看图推理和等待远端命令执行,202 次调用跑下来平均还是得 7.7 秒一轮。
这套运行时打包在官方 ChatGPT 桌面应用的 Resources/cua_node 目录下,版本跟桌面应用绑定,默认也只能配 Codex 自己的账号和模型使用。
三、用 LCU 解耦运行时
既然底层引擎是现成的,要想让其他 Agent 工具链也用上这套能力,只需要把接线层拆出来。开源项目 LCU(amontlabs/lcu)干的就是这件事,它完全保留了原有的 helper、Sky 服务、管道协议和审批机制,只在外层做探测与桥接:找到本机已安装的 ChatGPT.app(要求官方桌面端必须装着)、校验签名与目录结构、把 MCP 服务注册到目标客户端、管理进程生命周期,并补上一层审批界面。
装好之后,Claude Code、opencode 或 Antigravity 里都会多出一个名为 lcu 的 MCP 服务,对外同样只暴露一个 js 工具:
var app = await cua.getApp('com.google.Chrome');
await app.getScreenshot();
await app.click([2150, 690]);
await app.typeText('ssh root@10.0.0.1');
await app.pressKey('Return');
四、接入 Claude Code + DeepSeek
接下来就可以把外壳换成 Claude Code,后端模型换成 DeepSeek(走 https://api.deepseek.com/anthropic 兼容端点),底层操作继续调本机的 Swift 服务。按官方安装脚本执行:
./scripts/install.sh --agent claude-code
实际配的时候踩到了两个文档里没细说的坑。
第一个坑是安装器不支持自定义配置目录。如果机器上设置了 CLAUDE_CONFIG_DIR(比如为了把日常配置和独立环境分开),安装脚本会直接报错退出:
CLAUDE_CONFIG_DIR is not supported by the bundled installers for claude-code
这种情况只能手动把 MCP 配置项、hooks 和审批模块拷进对应的配置目录里,而且后续跑 lcu update 也不会自动更新这部分。
第二个坑是 macOS 不允许嵌套 Seatbelt 沙箱。如果外层的 Claude Code 启动时已经套了一层 sandbox-exec,里面的 node_repl 再调一次沙箱就会直接崩溃退出(code 71):
sandbox-exec: sandbox_apply: Operation not permitted
node_repl kernel exited unexpectedly ... exited(code=71)
解决办法是在 MCP 配置的环境变量里把 CODEX_CLI_PATH 置空(等价于给 node_repl 传 --disable-sandbox):
"env": { "CODEX_CLI_PATH": "" }
置空后重新调 cua.getState(),就能正常列出本机当前运行的 59 个应用窗口。
五、省单价与省 token 是两回事
把运行时接到 Claude Code + DeepSeek 上,省下的是每百万 token 的单价和账号绑定限制,token 消耗总量并不会减少。同样的任务跑下来,轮数和每轮累积的上下文体积是一样的,2870 万 token 换到 DeepSeek 上还是 2870 万 token,只是账单金额降了一个数量级。
如果真想把 token 数量降下来,得换观测方式:
| 观测方式 | 单步 token 量级 | 能否读取 Canvas 内容 | 适用场景 |
|---|---|---|---|
| DOM / ARIA 语义树快照 | 数十到数百 | 否 | 普通网页交互、数据抓取 |
| 窗口无障碍树(AX) | 数百到数千 | 否(Canvas 节点内部为空) | 原生桌面应用 |
| 整窗截图 | 1500~2000 | 是 | noVNC 远程桌面、纯画布界面 |
同一个网页任务,走浏览器扩展读 DOM 语义树(返回 @e12 这类节点引用,几十毫秒点完)可能几万 token 就跑完了;换成全屏截图估坐标,动辄就是上千万 token。因此在分工上,只要页面有 DOM 就优先走浏览器扩展或 CDP 方案;只有面对 noVNC 远程桌面、Canvas 画布或没有暴露 DOM 的原生桌面应用时,才动用 LCU 截图方案。
在必须走视觉操作远程桌面的场景下,还有几条实操规矩能少走弯路:
- 每次发键盘输入前先截图确认焦点所在窗口,执行完命令后再截一张确认终端回显,避免盲打把命令敲进错误的窗口里。
- 遇到包含多层引号、
$VAR变量替换或换行的长命令时,远端 shell 或输入法很容易吃字符,先在本地把命令转成 Base64,再通过echo <b64> | base64 -d | bash单行发过去最稳妥。 - 调试用的独立浏览器 Profile 与日常主浏览器的 Cookie 互不相通;如果目标站点没登录,直接停下来让人工处理,不要让模型自己反复试探登录或硬切 Profile。
- 首次操作某个桌面应用时系统会弹出权限审批框,需要人工点一次放行;长链路任务中途触发上下文压缩后会丢掉桌面当前状态,重要进度要及时写进本地文件。