把 Codex 的 Computer Use 接进 Claude Code

发布时间:

后续:不怎么用这个了,用 browserskill 感觉效果更好了。

一、 token

指标数值
工具调用202 次
截图43 张(3840×2100 JPEG,单张约 550KB)
总耗时26 分钟(平均每轮 7.7 秒)
总 token28,713,396
其中 input28,690,513
其中 cached input28,001,920(97.6%)
output22,883
reasoning6,061
单次请求峰值 input466,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 截图方案。

在必须走视觉操作远程桌面的场景下,还有几条实操规矩能少走弯路:

  1. 每次发键盘输入前先截图确认焦点所在窗口,执行完命令后再截一张确认终端回显,避免盲打把命令敲进错误的窗口里。
  2. 遇到包含多层引号、$VAR 变量替换或换行的长命令时,远端 shell 或输入法很容易吃字符,先在本地把命令转成 Base64,再通过 echo <b64> | base64 -d | bash 单行发过去最稳妥。
  3. 调试用的独立浏览器 Profile 与日常主浏览器的 Cookie 互不相通;如果目标站点没登录,直接停下来让人工处理,不要让模型自己反复试探登录或硬切 Profile。
  4. 首次操作某个桌面应用时系统会弹出权限审批框,需要人工点一次放行;长链路任务中途触发上下文压缩后会丢掉桌面当前状态,重要进度要及时写进本地文件。