一条命令,插进正在进行的一轮 左边一个终端里用 harnessctl send fix-tests.jsonl 发起任务,再用同一条命令发出“先别跑集成测试”,事件文件里记下一条 accepted;一根强调色的线从终端连到中间的适配器,再分成两路,分别插进右边两个 agent 正在推进的进度条中段。 $ harnessctl send fix-tests.jsonl "修复失败的测试" $ harnessctl send fix-tests.jsonl "先别跑集成测试" {"type":"accepted"}

在终端里用 Claude Code 或 Codex 时,插话是很自然的事。它在跑测试,我们看到它跑的是一组用不着的集成测试,就直接敲一句“先别跑集成测试,只修单元测试”,回车。Claude Code 会把这句话放进队列,等手头的工具调用结束后读到;Codex 也能把它 steer 进正在进行的这一轮。

可一旦想让脚本、定时任务或者另一个 agent 来驱动它们,事情就变了。发起还好办,两家都有非交互模式:

Terminal window
claude -p "修复 tests/ 下失败的测试" --output-format stream-json --verbose
codex exec --json "修复 tests/ 下失败的测试"

我们希望插话也是一条命令:

Terminal window
harnessctl send fix-tests.jsonl "修复 tests/ 下失败的测试" --cwd ~/repo
harnessctl send fix-tests.jsonl "先别跑集成测试,只修单元测试"

两条命令一模一样,也都很快返回。agent 说了什么、做了什么,这句话归到了哪一次交互,要不要批准某条命令,全都写进 fix-tests.jsonl。把 Codex 的适配器换成 Claude Code 的,命令一个字不改。要做到这一点,得先回答几个问题:

  1. 发起一次推理,两家的差别有多大?
  2. 为什么发起能用一条命令完成,插话却不行?
  3. 两家各自的插话机制差在哪里,能不能收进同一套语义?
  4. 市面上有没有现成的协议已经做了这件事?
  5. 如果没有,这个协议该长什么样,适配器能不能只是一包脚本?

本文顺着这几个问题一层一层往下讲,最后给出我们设计的协议 harnessctl,并在本机验证它的 Codex 适配器。


一、会话的发起

现象

上面两条非交互命令长得差不多:给一段提示词,拿回一串 JSON 事件,最后一条是结果。要接着聊,两家也都有续上的办法12:

Terminal window
# Claude Code:从 JSON 结果里取 session_id,再用 --resume 续上
sid=$(claude -p "开始审查这次改动" --output-format json | jq -r '.session_id')
claude -p "继续,重点看数据库查询" --resume "$sid"
# Codex:事件流的第一行是 {"type":"thread.started","thread_id":"0199a213-…"}
codex exec --json "开始审查这次改动"
codex exec resume 0199a213-… "继续,重点看数据库查询"

分析:一次性进程,加上磁盘上的会话

这两种用法背后是同一个模型:一个进程只跑一轮。进程启动,读入提示词,跑完这一轮(中间可能有几十次工具调用),把结果写到 stdout,然后退出。会话不在进程里,而是以对话记录的形式存在磁盘上,下一次 --resume 或 exec resume 时再读回来。

所以“发起”和“续上”很容易统一。把本地 Agent 变成平台上的 Bot 里提到过,Multica 为 26 种 agent CLI 做适配时,把差异收敛到一个只有一个方法的接口里3:

Go
type Backend interface {
Execute(ctx context.Context, prompt string, opts ExecOptions) (*Session, error)
}

要续上的会话 ID 放进 ExecOptions 就行。

可这个接口里没有“插话”。一次性进程跑起来以后,还有谁能跟它说话?


二、运行中会话的寻址

现象

最直觉的做法是再 resume 一次:第一条 claude -p 还在跑,另开一个终端执行 claude -p --resume "$sid" "先别跑集成测试"。

这句话插不进去。第二个进程从磁盘上读到的,是会话此刻保存下来的样子;它和第一个进程之间没有任何通道,第一个进程正在跑的那一轮根本不知道有这句话。结果是同一段对话被两个进程各自往下写。(Claude Code 新近的后台会话对这种情况做了特殊处理,第四节再讲。)

原因

会话有 ID,但正在运行的那一轮没有地址。插话的本质,是把一条消息送进一个还活着的进程。命令行参数在进程启动时就读完了,之后能进去的只有 stdin 这一条管道,而这条管道握在启动它的那个脚本手里。

两家其实都留了长连接的入口。Claude Code 加上 --input-format stream-json 之后,claude -p 不再读一次就结束,而是持续从 stdin 读 JSON 消息;Codex 的 app-server 是一个常驻进程,在 stdio 上跑 JSON-RPC。问题只在于,怎么让另一条命令也用上这条连接。

第一次尝试:用命名管道留住 stdin

对 Claude Code,可以把 stdin 接到一个命名管道上,谁想插话,就往管道里写一行:

Terminal window
mkfifo /tmp/fix.in
tail -f /tmp/fix.in | claude -p --input-format stream-json \
--output-format stream-json --verbose > /tmp/fix.out &
echo '{"type":"user","message":{"role":"user","content":"修复失败的测试"},"parent_tool_use_id":null}' > /tmp/fix.in
# 过一会儿,另一个脚本:
echo '{"type":"user","message":{"role":"user","content":"先别跑集成测试"},"parent_tool_use_id":null}' > /tmp/fix.in

看起来已经是“一条命令插话”了,但很快会碰到三个问题:

  • 没有回执。 echo 只要把字节写进管道就返回。这句话是插进了当前这一轮,还是排到了下一轮,甚至 claude 是不是早就退出了,写入方都不知道。
  • 输出没人整理。 所有原始输出都流进 /tmp/fix.out,想知道“我刚才那句话之后发生了什么”,只能自己去翻。
  • Codex 用不了。 JSON-RPC 是一问一答。turn/steer 必须带上当前这一轮的 expectedTurnId,这个 ID 要从 stdout 上读出来;Codex 请求审批时,又是服务器发给客户端的一个请求,必须有人回应。单向的管道做不了这些。

解决:让事件文件充当会话的地址

回头看这三个问题,它们指向同一件事:调用方和 agent 之间,缺一个双方都找得到、又不随进程消失的东西。

最简单的这种东西就是一个文件。于是协议这样约定:调用方给出一个文件路径,对这个会话的每条命令都带上同一个路径;agent 的一切输出,包括回答、工具进度、输入的归属、要不要批准某条命令,都以一行 JSON 追加进这个文件。

事件文件的路径就是会话的地址 左边的调用方执行 harnessctl send、cancel、reply,每条命令都带着同一个文件路径 fix-tests.jsonl;中间的 codex 适配器包里有 harnessctl、manifest.json 和 lib/runtime.py,运行时按需拉起一个运行进程和 codex app-server,这部分协议不管;适配器把事件追加进右边的 fix-tests.jsonl,里面依次是 accepted、request、message、log、result;调用方用 tail -f 读这个文件。 调用方 · 任意脚本 适配器包 · codex 事件文件 追加 tail -f 读取,按完整行数推进 $ harnessctl send 发起 fix-tests.jsonl "修复…" $ harnessctl send 插话 fix-tests.jsonl "先别跑…" $ harnessctl cancel 打断 fix-tests.jsonl $ harnessctl reply 审批 fix-tests.jsonl req-1 deny $ tail -f fix-tests.jsonl 包内容 24 KB harnessctl 入口 manifest.json 版本、依赖 lib/runtime.py 运行时 运行进程(按需启动) → codex app-server ↻ 空闲回收,下次恢复 虚线:适配器内部怎么做, 协议不规定。 fix-tests.jsonl 只追加 accepted · R1 · in-1 accepted · R1 · in-2 log · text.delta request · pending request · replied message result · R1 · completed … 调用方只读这一个文件。
图 1 · 每条命令都带着同一个文件路径,agent 的一切输出都追加进这个文件;适配器内部怎么保活,协议不管

三个问题就都有了着落。回执是文件里的一行事件;文件谁都能读,断开了也能从任意一行接着读;Codex 需要的一问一答、Claude Code 需要的一条常开的 stdin,都关在适配器里面。

那个必须一直活着的进程并没有消失,只是成了适配器的内部实现,调用方看不到,协议也不规定。第六节会看到它在实现里的样子。

会话有了地址,接下来的问题是:适配器收到一句“先别跑集成测试”,该怎么交给 agent?两家在这一步的做法相当不同。


三、插话语义的差异

Claude Code:消息上的 priority

--input-format stream-json 模式下,往 stdin 写的每一行都是一条用户消息。如果它在一轮还没结束时到达,默认的优先级是 next:Claude 在同一轮里读到它,时机是手头正在跑的工具调用结束之后;如果这一轮先结束了,它就开启下一轮4。

打断走另一条线:写一行 type 为 control_request、子类型为 interrupt 的消息,Claude Code 回一个 control_response5。

Codex:带前置条件的 turn/steer

Codex 的 app-server 把插话做成了一个独立的 JSON-RPC 方法 turn/steer6。它的 expectedTurnId 参数是必填的,schema 里的说明是:这是一个前置条件,和当前活动的那一轮对不上,请求就失败7。打断用 turn/interrupt,带上 threadId 和 turnId。

我在本机用 codex-cli 0.165.0 实测了这几种情况。没有登录,所以模型请求会失败,但轮次的开始、插话和打断都照常发生:

→ {"method":"turn/steer","id":"in_11","params":{…,"expectedTurnId":"01a19675-9014-…",…}}
← {"id":"in_11","result":{"turnId":"01a19675-9014-71e2-b5f6-b6d450d616b6"}}
→ {"method":"turn/steer","id":"in_12","params":{…,"expectedTurnId":"wrong-turn",…}}
← {"error":{"code":-32600,"message":"expected active turn id `wrong-turn` but found `01a19675-9014-71e2-b5f6-b6d450d616b6`"},"id":"in_12"}
(这一轮结束之后再 steer)
← {"error":{"code":-32600,"message":"no active turn to steer"},"id":"in_6"}

同一句话在两条线上

同一句插话在两家线上的报文 左边是 Claude Code 的 stdin 和 stdout:写入一条带 next 优先级的用户消息“先别跑集成测试”后,没有直接回应,之后才看到工具结果、助手回答和 result;打断用 control_request interrupt,收到 control_response。右边是 Codex 的 JSON-RPC:turn/steer 带 expectedTurnId turn_7,立刻得到 turnId turn_7 的响应;打断用 turn/interrupt,响应为空对象。 Claude Code · claude -p Codex · codex app-server stdin → / ← stdout NDJSON →user"修复失败的测试" ←system/initsession_id 9c1e… ←assistanttool_use Bash →user"先别跑集成测试" · next (没有直接回应) ←usertool_result · pytest ←assistant"好,先只修单元测试" ←resultsubtype success →control_requestsubtype interrupt ←control_responsesubtype success 请求 → / ← 响应与通知 JSON-RPC →turn/start"修复失败的测试" ←turn/startedturn.id turn_7 ←item/startedcommandExecution →turn/steerturn_7 "先别跑集成测试" ←id:3 resultturnId turn_7 ←item/completedcommandExecution ←item/completed"好,先只修单元测试" ←turn/completedstatus completed →turn/interruptturn_7 ←id:4 result{} 虚线:Claude Code 不回应这条消息, 它落在哪一轮只能看后面的事件。 Codex 当场回执;turn_7 若已结束, 这条请求直接报错。
图 2 · 同一句插话,Codex 当场告诉你它进了哪一轮,Claude Code 要从后续事件里推断(报文按官方文档和 schema 简写,不是抓包)

把图 2 两边对照,差别集中在两处。

回执。 Codex 当场告诉你这句话进了哪一轮;Claude Code 对这条消息不作任何直接回应。--replay-user-messages 会把收到的消息回显到 stdout,能确认“收到了”,但不说明落在哪一轮。

竞态。 插话的那一刻,这一轮恰好结束了怎么办?Claude Code 的 next 会自动顺延成下一轮;Codex 则直接报错 no active turn to steer,由客户端决定怎么办。

这两处差异,都得在协议里有个交代。动手设计之前,先看看是不是已经有人做了。


四、现有方案的调研

ACP:协议有了,插话还没进标准

最接近的是 ACP(Agent Client Protocol),Zed 牵头的编辑器与 agent 之间的协议。它基于 JSON-RPC,agent 通常作为子进程跑在 stdio 上:session/new 建会话,session/prompt 发起一轮,session/cancel 打断8。Claude Code 和 Codex 都有 ACP 适配器,codex-acp 内部启动的就是 Codex 的 app-server。

但 ACP v1 里没有往正在运行的一轮追加输入的请求。两家的适配器先各自实现了一个预标准扩展 _session/steering9,把它写进标准的提案还没有合并10。10 月底合并的 PR #2627 规定,agent 忙的时候也要接受 session/prompt,但收到后是插进当前一轮还是排到下一轮,由 agent 自己决定11。

ACP 本身也不是“一条命令”的形态,它需要一个持续的连接。

acpx:ACP 的命令行客户端

acpx 补的正是这一层。它是 ACP 的无头命令行客户端,内置了 claude、codex 等几十个 agent 的配置12:

Terminal window
acpx codex sessions new --name backend
acpx codex -s backend "修复 API 分页的 bug"
acpx codex -s backend --no-wait "顺便把测试补上"
acpx codex cancel -s backend

只是 --no-wait 的语义是排到下一轮,不是插进当前这一轮。文档说得很直接:“ACP has no request that adds input to a prompt that is already running”13。给 acpx 加 steer 子命令的 issue 还开着14。

审批方面,acpx 在非交互模式下会直接拒绝需要人工批准的请求15,没有事后用另一条命令批准的办法。

厂商自己的守护进程

两家厂商也各自做了后台托管,但都不是给脚本插话用的。

Claude Code 的后台会话。 claude --bg "任务" 把会话交给一个 supervisor 进程托管,claude stop <id> 停止16。对运行中的后台会话执行 claude --resume <id> "提示词",提示词会作为下一轮发过去;但只要带了管道或重定向,它就什么都不发,以状态码 1 退出17。

Codex 的常驻 app-server。 本机的 codex-cli 0.165.0 里,codex app-server 多了 daemon 和 proxy 两个子命令。一次性的命令可以经 proxy 连上常驻服务,但连上之后说的仍是完整的 JSON-RPC。

其他方案

coder/agentapi 用一个内存里的终端模拟器,把 Claude Code、Codex 等包成 HTTP 接口,靠发按键模拟插话,效果取决于各家 TUI 的行为,项目也已不再维护18。A2A 是服务之间的 agent 通信协议19,AG-UI 是 agent 和前端界面之间的事件协议20,MCP 是 agent 调用工具的方向21,都不是“驱动一个编码 agent 会话”的形态。

谁能用一条命令做什么
方案覆盖两家一条命令发起一条命令打断一条命令插进当前一轮
ACP 协议本身是,经适配器否,需要常驻连接适配器扩展 _session/steering,未标准化
acpx是是是,cancel否,--no-wait 只排到下一轮
Claude Code 后台会话只有 Claude Code是,--bg只有 stop,停掉整个会话否,--resume 发的是下一轮,且拒绝脚本调用
Codex app-server daemon只有 Codex否,要经 proxy 说完整的 JSON-RPC协议里有 turn/steer,但不是一条命令
coder/agentapi是是,HTTP靠发按键,取决于各家 TUI;已停止维护
A2A / AG-UI / MCP不是驱动编码 agent 会话的形态

核实于 2026-11-01。“一条命令”指一次性的 shell 调用,不需要调用方自己维持连接。

表 1 · 最右一列没有一个“是”:发起和打断都有人做了,插进当前一轮的那条命令还没有

结论是:没有现成的协议同时满足“跨 Claude Code 和 Codex、一条命令发起、一条命令在进行中插话”。最接近的是 acpx,它缺三样东西:插进当前一轮的命令、插话的回执,以及脱离终端的异步审批。

那就自己设计一个。


五、协议的设计

我们设计的这个协议叫 harnessctl,实现开源在 AgentUse 仓库22。设计时只守一条原则:调用方只说自己要什么,怎么做到都是适配器的事。 落下来就是三样东西:四条命令、一个事件文件、一个独立的适配器包。

四条命令

harnessctl send <events.jsonl> [<prompt>] [--cwd DIR] [--model MODEL]
harnessctl cancel <events.jsonl>
harnessctl status <events.jsonl>
harnessctl reply <events.jsonl> <request_id> [<answer>]

send、cancel、reply 受理后很快返回,成功时 stdout 为空,结果都看事件文件。status 输出一行 JSON,讲的是这段对话的状态(idle、running、awaiting_reply、cancelling、failed),不是进程的状态。正文可以从 stdin 传,多行提示词不用再操心 shell 转义:

Terminal window
printf '%s' "$prompt" | "$A" send fix-tests.jsonl --cwd ~/repo

发起和插话是同一条 send,调用方不用先判断会话在不在、这一轮结束了没有。这些判断它也做不好,因为状态在它读到的下一刻就可能变了。

退出码 0 只表示已受理,不代表模型读过这句话,更不代表任务成功。命令如果超时,这句话可能已经被受理了,所以不能盲目重发,要先读事件文件。

事件文件的路径就是会话

规范化之后的绝对路径是会话的唯一身份,协议里没有另外的 session ID。第一次 send 自动创建事件文件,并把适配器、cwd、model 绑定到这个地址上。之后可以省略它们;传了不同的值就报错。想换工作区,就换一个事件文件。

send:由适配器决定投递

第三节那些差异,全部在这里消化。两个适配器收到同样的两条命令,做法不同,但都遵守同一条规则:只公布确认过的归属。

同一条 send 在两个适配器的事件文件里 两个适配器都收到两条 send。Codex 适配器在内部发 turn/steer,拿到原生回执后写 accepted,第二句话 in-2 和第一句 in-1 同属 run R1,一个 result 同时覆盖两句;如果原生回复这一轮已结束,in-2 改属新的 run R2。Claude Code 适配器没有可靠回执,把 in-2 排进内部队列,分配 R2,R1 结束后才提交,两个 run 各有一个 result。 Codex 适配器 · 有原生回执 Claude Code 适配器 · 没有轮次回执 命令与事件,从上往下 fix-tests.jsonl $ send"修复测试" acceptedR1 · in-1 $ send"先别跑集成测试" 内部 turn/steer → result.turnId acceptedR1 · in-2 message"已修复单元测试" resultR1 · [in-1, in-2] 或者:原生回 no active turn,这一轮刚结束 acceptedR2 · in-2 resultR2 · [in-2] 命令与事件,从上往下 fix-tests.jsonl $ send"修复测试" acceptedR1 · in-1 $ send"先别跑集成测试" 忙:证明不了归属,放进适配器的队列 acceptedR2 · in-2 message"已修复测试" resultR1 · [in-1] R1 结束后,才把 in-2 写进 stream-json message"好,只修单元测试" resultR2 · [in-2] 虚线:适配器内部发生的事,不进关键事件。 R1、R2 代表适配器分配的 UUID;两侧的调用方写的命令完全相同。
图 3 · 调用方不选投递方式:Codex 适配器拿到回执才公布归属,Claude Code 适配器证明不了归属,就排到下一个 run(Codex 的 run 归属来自实测,正文内容和 Claude Code 一侧按代码流程示意)

Codex 适配器在内部发 turn/steer,拿到回执后才写 accepted,这句话归入当前这次交互。如果原生回的是这一轮刚结束,就分配新的 run_id,开启下一轮23。

Claude Code 适配器没有可靠的轮次回执,忙的时候就先留在自己的队列里,这一轮结束后再提交24。代价很直接:在 Claude Code 上,send 目前做不到“工具调用结束就读到”的插话,要等这一轮结束。我们宁可晚一点,也不在文件里写一个证明不了的归属。

所以这个协议不保证一句话落在哪里,只保证事后告诉你它落在了哪里。想立刻换方向,就先 cancel 再 send。cancel 打断当前执行,清空还没处理的输入,但保留对话历史;已经发生的工具副作用撤销不了。

事件:能不能丢

事件只有五种,按能不能丢分成两层25:

五种事件
类型内容可靠性
accepted完整输入、input_id、确认的 run_id校验、加锁、fsync 之后,send 才返回成功
message完整正文快照,同一 message_id 整条替换关键,校验、加锁、fsync,不能靠丢日志丢弃
request审批或提问的 pending、replied、expired 快照
result一个 run 的终态(completed、cancelled、failed)、input_ids、输出和计量
log流式片段、工具进度、原生回执、进程诊断可丢、可合并、可限流,写失败不影响工作结果
表 2 · 前四种足够重建全部业务状态,所以第五种可以随便丢

分层的标准是:把所有 log 都删掉,业务状态能不能完整重建? 所以流式片段只能放进 log,正文一定要有一条完整的 message;致命错误一定要进 result,不能只写一条日志。原生报文也只允许出现在 log 里,换一个 agent,调用方读到的关键事件结构不变。

一句话的结局也不用自己拼。run_id 由适配器分配,result.input_ids 直接列出这次结果覆盖了哪几句话。下面三行来自本机实测,第二行就是插话,它和第一句共用一个 run_id:

JSON
{"type":"accepted","run_id":"fd923206-…","input_id":"bb5fd1b9-…","text":"修复 tests/ 下失败的测试"}
{"type":"accepted","run_id":"fd923206-…","input_id":"97b7e1dd-…","text":"先别跑集成测试,只修单元测试"}
{"type":"result","run_id":"fd923206-…","input_ids":["bb5fd1b9-…","97b7e1dd-…"],"status":"failed","output":"","usage":null,"cost_usd":null,"error":"unexpected status 401 Unauthorized…"}

审批:也是一行事件加一条命令

agent 需要外部决定时,文件里出现一条 pending 的 request:

JSON
{"type":"request","ts":"…","run_id":"550e8400-…","request_id":"req-1","state":"pending","kind":"permission","message":"执行 npm test?"}

调用方可以过一会儿再用另一条命令回答,提问类请求也一样:

Terminal window
"$A" reply fix-tests.jsonl req-1 deny
printf '%s' '{"q1":"Go"}' | "$A" reply fix-tests.jsonl req-2

到两家原生调用的映射

harnessctl 命令到两家原生调用
harnessctlClaude Code · stream-jsonCodex · app-server
send
首次claude -p --input-format stream-json --output-format stream-json --verbose --replay-user-messages --permission-prompt-tool stdio,写一条用户消息codex app-server --listen stdio://,initialize、thread/start、turn/start
回收后续上同上,加 --resume <session>thread/resume、turn/start
正在忙适配器内部排队,这一轮结束后再写用户消息turn/steer 带 expectedTurnId;这一轮刚结束就改为下一轮
其他
cancelcontrol_request · interruptturn/interrupt;还没拿到 turn id 时先记下,拿到后再发
reply · 审批control_response,behavior: allow / denyJSON-RPC 响应,decision: accept / decline
reply · 提问AskUserQuestion 的 updatedInput.answersitem/tool/requestUserInput 的 answers

status 只查适配器自己的状态,不需要映射。

表 3 · 每条命令在两家都找得到对应的原生调用,差异全部收在适配器里(读自两个后端模块)

协议里已经没有进程了。可第二节说过,必须有一个活着的进程握着原生连接。它去哪了?


六、参考实现:运行进程

按需启动,空闲回收

答案是每个地址一个运行进程,由 send 按需拉起。它持有原生连接、队列和待回复请求26:

一句插话在 Codex 适配器内部走过的路 harnessctl send 经本机 IPC 把话交给运行进程;运行进程向 codex app-server 发 turn/steer,收到 result.turnId 后,把 accepted 校验、加锁、fsync 写进 fix-tests.jsonl,send 这才以 0 退出。恢复标识、绑定配置和运行锁放在旁边的 fix-tests.jsonl.d 目录。调用方用 tail -f 读事件文件。 适配器内部 · 一个事件文件地址 本机 IPC 退出码 0 turn/steer result.turnId 校验 → 加锁 → fsync $ harnessctl send "先别跑集成测试" lib/runtime.py _worker 队列 · run_id · 待回复请求 原生连接 · 完整正文 运行进程 · 按需启动,空闲回收 codex app-server fix-tests.jsonl 公开 accepted · R1 · in-2 … $ tail -f fix-tests.jsonl.d/ 私有 config.json 绑定 cwd、model native.json 原生恢复标识 runtime.lock 失联检测 虚线:运行进程属于实现,协议里没有它。send 等到原生回执才写 accepted,不等模型回答。
图 4 · 常驻的是一个按需启动的运行进程;插话的归属由原生回执确认之后,才写进事件文件

命令和运行进程之间走本机 socket,但 socket 只出现在实现里,换掉它不影响任何调用方。这正是把“每个动作一条命令”作为协议边界的好处。

回收。 没有活动执行、待处理输入和待回复请求时,运行进程空闲约 60 秒后退出,下一次 send 接着原来的上下文继续。所以协议不需要“关闭”这个动作。

失联。 运行进程被 kill -9 之后,下一条命令能通过文件锁发现它没了。这时适配器把还没有结果的输入记成 failed,但不重放:它们可能已经执行了一半,再跑一遍可能重复产生副作用。

适配器还是“一包脚本”。Codex 的包打出来约 24 KB,只依赖 Linux 和 Python 3.9 以上的标准库,以及用户自己装好并登录的 codex。代价是代码量约 1100 行,大头是运行进程;目前也只支持 Linux。

实测

我们从打好的 tar.gz 解压,用本机 codex-cli 0.165.0 跑了一遍。没有登录,所以模型请求会以 401 失败,但轮次的开始、插话、打断、回收和恢复都照常发生:

Terminal window
$ A=./codex/harnessctl
$ "$A" send fix.jsonl "修复 tests/ 下失败的测试" --cwd repo # 168 ms,含拉起运行进程
$ "$A" status fix.jsonl
{"state": "running", "run_id": "fd923206-…", "queued": 0, "requests": [], "cwd": "/tmp/hc-run4/repo", "model": null}
$ "$A" send fix.jsonl "先别跑集成测试,只修单元测试" # 134 ms,已拿到原生回执

接着试了几种边角情况:

  • 换一个 --cwd 再 send:退出码 2,cwd is bound to this event path。
  • 空闲 72 秒后:运行进程已退出。再 send,175 ms 返回,还是同一个 thread。
  • 运行中 kill -9 运行进程:下一次 status 补写一条 failed 的 result(Runtime lost; input outcome is uncertain)。再 send 恢复同一个 thread,旧输入没有重放。
  • 运行中 cancel:这句话得到一条 cancelled 的 result,状态回到 idle。

仓库自带的 29 个测试也全部通过,它们用一个假的后端驱动两个适配器。

还没有验证的:真实的审批路径(未登录时模型不会去执行命令,request 和 reply 只在假后端上跑过),以及真实的 Claude Code(本机没有安装,这一侧来自读代码和假后端测试)。

拿来用

Terminal window
git clone --depth 1 https://github.com/memory-co/agentuse.git
python3 agentuse/harnessctl/package.py --adapter codex --output-dir ./dist
mkdir -p ~/.harness && tar xzf dist/harnessctl-codex-*-linux.tar.gz -C ~/.harness
~/.harness/codex/harnessctl status /tmp/none.jsonl # 退出码 4:地址不存在,说明入口可用

七、回到起点

回到开头那两条命令:

Terminal window
A=~/.harness/codex/harnessctl
"$A" send fix-tests.jsonl "修复 tests/ 下失败的测试" --cwd ~/repo
"$A" send fix-tests.jsonl "先别跑集成测试,只修单元测试"

两条命令都在零点几秒内返回。第二句话在 Codex 上并入同一个 run_id,在 Claude Code 上排到下一个 run_id,事件文件都如实记下了。把 A 换成 Claude Code 适配器的路径,其余一个字不改。


小结

  1. 发起一次推理,两家差别大吗? 不大。两家都是一个进程跑一轮,会话存在磁盘上。
  2. 为什么插话做不到一条命令? 正在运行的那一轮没有地址,而插话要把消息送进一个活着的进程。
  3. 怎么给运行中的会话一个地址? 用一个事件文件的路径,一切输出都追加进这个文件。
  4. 两家的插话差在哪? Claude Code 没有轮次回执;Codex 当场回执,这一轮结束了就报错。
  5. 有现成的协议吗? 没有完全满足的。发起和打断都有人做了,一条命令插进当前一轮还没有。
  6. harnessctl 怎么设计? 四条命令 send、cancel、status、reply,由适配器决定投递,只公布确认过的归属;四种关键事件足够重建业务状态,log 可以丢。
  7. 那个常驻进程去哪了? 变成每个地址一个按需启动的运行进程,空闲回收,失联时不重放。它只是实现细节,协议里没有它。

参考资料

Footnotes

  1. Run Claude Code programmaticallyClaude Code Docscode.claude.com ↗--output-format json 取 session_id 再 --resumehttps://code.claude.com/docs/en/headless ↩

  2. Codex non-interactive modeOpenAI Developersdevelopers.openai.com ↗codex exec --json 的事件类型,codex exec resumehttps://developers.openai.com/codex/noninteractive ↩

  3. server/pkg/agent/agent.goMultica 源码收藏github.comhttps://github.com/multica-ai/multica/blob/main/server/pkg/agent/agent.go ↩

  4. Agent SDK reference - TypeScriptClaude Code Docscode.claude.com ↗SDKUserMessage.priorityhttps://code.claude.com/docs/en/agent-sdk/typescript ↩

  5. claude-agent-sdk-python · query.pyGitHubgithub.com ↗control_request / control_response 信封,interrupt 与 can_use_tool 两种子类型https://github.com/anthropics/claude-agent-sdk-python/blob/main/src/claude_agent_sdk/_internal/query.py ↩

  6. Codex App ServerOpenAI Developersdevelopers.openai.com ↗JSON-RPC 2.0,线上省略 "jsonrpc":"2.0";turn/steer、turn/interrupthttps://developers.openai.com/codex/app-server ↩

  7. codex app-server generate-json-schema 的输出本机 codex-cli 0.165.0 生成。TurnSteerParams.expectedTurnId:“Required active turn id precondition. The request fails when it does not match the currently active turn.”;CommandExecutionApprovalDecision 取值 accept / decline 等 ↩

  8. ACP · Prompt TurnAgent Client Protocolagentclientprotocol.com ↗https://agentclientprotocol.com/protocol/v1/prompt-turn ↩

  9. codex-acp · Session steeringGitHubgithub.com ↗预标准扩展 _session/steering,claude-agent-acp 也实现了同名扩展https://github.com/agentclientprotocol/codex-acp/blob/main/docs/steering.md ↩

  10. RFD: steer and queue prompts sent while the Agent is workingGitHubgithub.com ↗截至 2026-11-01 仍为 openhttps://github.com/agentclientprotocol/agent-client-protocol/pull/1261 ↩

  11. docs(protocol): define prompts sent during active workGitHubgithub.com ↗2026-10-29 合并https://github.com/agentclientprotocol/agent-client-protocol/pull/2627 ↩

  12. openclaw/acpxGitHubgithub.com ↗https://github.com/openclaw/acpx ↩

  13. acpx · PromptingGitHubgithub.com ↗“ACP has no request that adds input to a prompt that is already running”https://github.com/openclaw/acpx/blob/main/docs/prompting.md ↩

  14. acpx #836: Support the adapters' `_session/steering` extensionGitHubgithub.com ↗截至 2026-11-01 仍为 openhttps://github.com/openclaw/acpx/issues/836 ↩

  15. acpx · PermissionsGitHubgithub.com ↗https://github.com/openclaw/acpx/blob/main/docs/permissions.md ↩

  16. Manage multiple agents with agent viewClaude Code Docscode.claude.com ↗claude --bg、claude agents --json、claude stop,以及 supervisor 进程https://code.claude.com/docs/en/agent-view ↩

  17. Resume a running background sessionClaude Code Docscode.claude.com ↗带管道、重定向或 --output-format json 时什么都不发,以状态码 1 退出https://code.claude.com/docs/en/sessions ↩

  18. coder/agentapiGitHubgithub.com ↗https://github.com/coder/agentapi ↩

  19. A2A Protocol SpecificationA2Aa2a-protocol.org ↗https://a2a-protocol.org/latest/specification/ ↩

  20. ag-ui-protocol/ag-uiGitHubgithub.com ↗https://github.com/ag-ui-protocol/ag-ui ↩

  21. Model Context Protocol SpecificationMCPmodelcontextprotocol.io ↗https://modelcontextprotocol.io/specification/2025-06-18 ↩

  22. harnessctl/2:本机 Harness CLI 协议memory-co/agentusegithub.com ↗协议规范,提交 91bb42dhttps://github.com/memory-co/agentuse/blob/91bb42d82160d226c7f5337565879ffcf384685b/harnessctl/README.md ↩

  23. harness/codex/lib/codex.pymemory-co/agentusegithub.com ↗STEER_WHILE_BUSY = True,只把 no active turn 和 expected active turn 当作竞态https://github.com/memory-co/agentuse/blob/91bb42d82160d226c7f5337565879ffcf384685b/harnessctl/harness/codex/lib/codex.py ↩

  24. harness/claudecode/lib/claudecode.pymemory-co/agentusegithub.com ↗STEER_WHILE_BUSY = False;“replay is receipt, and result/echo ordering alone does not prove model consumption or turn”https://github.com/memory-co/agentuse/blob/91bb42d82160d226c7f5337565879ffcf384685b/harnessctl/harness/claudecode/lib/claudecode.py ↩

  25. harnessctl/2:五种公共 JSONL 事件memory-co/agentusegithub.com ↗https://github.com/memory-co/agentuse/blob/91bb42d82160d226c7f5337565879ffcf384685b/harnessctl/EVENT.md ↩

  26. harness/codex/lib/runtime.pymemory-co/agentusegithub.com ↗运行进程、队列、失联恢复https://github.com/memory-co/agentuse/blob/91bb42d82160d226c7f5337565879ffcf384685b/harnessctl/harness/codex/lib/runtime.py ↩