﻿---
title: "一条命令插话：设计一个抹平 Claude Code 与 Codex 差异的 agent CLI 协议"
description: "想在脚本里用一条命令让 Claude Code 或 Codex 开始干活，再用一条命令在它干到一半时插一句话。顺着这个需求往下追：为什么插话比发起难，两家的插话差在哪，现有方案做到了哪一步；最后给出我们设计的协议 harnessctl：四条命令、一个事件文件，由适配器决定投递，只公布确认过的归属，并在本机验证 Codex 适配器。"
pubDate: 2026-11-01
updatedDate: 2026-11-02
tags: ["AI", "Agent", "协议设计", "Claude Code", "Codex", "CLI"]
clc: "TP311.5"
hero: "./hero.svg"
refs:
  - id: cc-headless
    title: Run Claude Code programmatically
    url: https://code.claude.com/docs/en/headless
    site: Claude Code Docs
    note: "`--output-format json` 取 `session_id` 再 `--resume`"
  - id: cc-ts
    title: Agent SDK reference - TypeScript
    url: https://code.claude.com/docs/en/agent-sdk/typescript
    site: Claude Code Docs
    note: "`SDKUserMessage.priority`"
  - id: cc-sdk-py
    title: claude-agent-sdk-python · query.py
    url: https://github.com/anthropics/claude-agent-sdk-python/blob/main/src/claude_agent_sdk/_internal/query.py
    site: GitHub
    note: "`control_request` / `control_response` 信封，`interrupt` 与 `can_use_tool` 两种子类型"
  - id: cc-sessions
    title: Resume a running background session
    url: https://code.claude.com/docs/en/sessions
    site: Claude Code Docs
    note: "带管道、重定向或 `--output-format json` 时什么都不发，以状态码 1 退出"
  - id: cc-agentview
    title: Manage multiple agents with agent view
    url: https://code.claude.com/docs/en/agent-view
    site: Claude Code Docs
    note: "`claude --bg`、`claude agents --json`、`claude stop`，以及 supervisor 进程"
  - id: cx-appserver
    title: Codex App Server
    url: https://developers.openai.com/codex/app-server
    site: OpenAI Developers
    note: "JSON-RPC 2.0，线上省略 `\"jsonrpc\":\"2.0\"`；`turn/steer`、`turn/interrupt`"
  - id: cx-noninteractive
    title: Codex non-interactive mode
    url: https://developers.openai.com/codex/noninteractive
    site: OpenAI Developers
    note: "`codex exec --json` 的事件类型，`codex exec resume`"
  - id: cx-schema
    title: codex app-server generate-json-schema 的输出
    note: "本机 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` 等"
  - id: multica
    title: server/pkg/agent/agent.go
    url: https://github.com/multica-ai/multica/blob/main/server/pkg/agent/agent.go
    collect: "github-multica-ai-multica-server-pkg-agent-agent"
    site: Multica 源码
  - id: acp-prompt
    title: ACP · Prompt Turn
    url: https://agentclientprotocol.com/protocol/v1/prompt-turn
    site: Agent Client Protocol
  - id: codex-acp-steering
    title: codex-acp · Session steering
    url: https://github.com/agentclientprotocol/codex-acp/blob/main/docs/steering.md
    site: GitHub
    note: "预标准扩展 `_session/steering`，claude-agent-acp 也实现了同名扩展"
  - id: acp-1261
    title: "RFD: steer and queue prompts sent while the Agent is working"
    url: https://github.com/agentclientprotocol/agent-client-protocol/pull/1261
    site: GitHub
    note: "截至 2026-11-01 仍为 open"
  - id: acp-2627
    title: "docs(protocol): define prompts sent during active work"
    url: https://github.com/agentclientprotocol/agent-client-protocol/pull/2627
    site: GitHub
    note: "2026-10-29 合并"
  - id: acpx
    title: openclaw/acpx
    url: https://github.com/openclaw/acpx
    site: GitHub
  - id: acpx-prompting
    title: acpx · Prompting
    url: https://github.com/openclaw/acpx/blob/main/docs/prompting.md
    site: GitHub
    note: "“ACP has no request that adds input to a prompt that is already running”"
  - id: acpx-836
    title: "acpx #836: Support the adapters' `_session/steering` extension"
    url: https://github.com/openclaw/acpx/issues/836
    site: GitHub
    note: "截至 2026-11-01 仍为 open"
  - id: acpx-permissions
    title: acpx · Permissions
    url: https://github.com/openclaw/acpx/blob/main/docs/permissions.md
    site: GitHub
  - id: agentapi
    title: coder/agentapi
    url: https://github.com/coder/agentapi
    site: GitHub
  - id: a2a
    title: A2A Protocol Specification
    url: https://a2a-protocol.org/latest/specification/
    site: A2A
  - id: agui
    title: ag-ui-protocol/ag-ui
    url: https://github.com/ag-ui-protocol/ag-ui
    site: GitHub
  - id: mcp
    title: Model Context Protocol Specification
    url: https://modelcontextprotocol.io/specification/2025-06-18
    site: MCP
  - id: hc-readme
    title: "harnessctl/2：本机 Harness CLI 协议"
    url: https://github.com/memory-co/agentuse/blob/91bb42d82160d226c7f5337565879ffcf384685b/harnessctl/README.md
    site: memory-co/agentuse
    note: "协议规范，提交 `91bb42d`"
  - id: hc-event
    title: "harnessctl/2：五种公共 JSONL 事件"
    url: https://github.com/memory-co/agentuse/blob/91bb42d82160d226c7f5337565879ffcf384685b/harnessctl/EVENT.md
    site: memory-co/agentuse
  - id: hc-runtime
    title: harness/codex/lib/runtime.py
    url: https://github.com/memory-co/agentuse/blob/91bb42d82160d226c7f5337565879ffcf384685b/harnessctl/harness/codex/lib/runtime.py
    site: memory-co/agentuse
    note: "运行进程、队列、失联恢复"
  - id: hc-codex
    title: harness/codex/lib/codex.py
    url: https://github.com/memory-co/agentuse/blob/91bb42d82160d226c7f5337565879ffcf384685b/harnessctl/harness/codex/lib/codex.py
    site: memory-co/agentuse
    note: "`STEER_WHILE_BUSY = True`，只把 `no active turn` 和 `expected active turn` 当作竞态"
  - id: hc-claude
    title: harness/claudecode/lib/claudecode.py
    url: https://github.com/memory-co/agentuse/blob/91bb42d82160d226c7f5337565879ffcf384685b/harnessctl/harness/claudecode/lib/claudecode.py
    site: memory-co/agentuse
    note: "`STEER_WHILE_BUSY = False`；“replay is receipt, and result/echo ordering alone does not prove model consumption or turn”"
---

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

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

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

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

```bash
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 事件，最后一条是结果。要接着聊，两家也都有续上的办法[^cc-headless][^cx-noninteractive]：

```bash
# 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](/blog/local-agent-as-bot/) 里提到过，Multica 为 26 种 agent CLI 做适配时，把差异收敛到一个只有一个方法的接口里[^multica]：

```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 接到一个命名管道上，谁想插话，就往管道里写一行：

```bash
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 追加进这个文件。

<figure class="dd dd-figure">
<div class="dd-scroll">
<svg class="dd-svg" viewBox="0 0 800 400" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="acli-arch-title acli-arch-desc">
<title id="acli-arch-title">事件文件的路径就是会话的地址</title>
<desc id="acli-arch-desc">左边的调用方执行 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 读这个文件。</desc>
<defs>
<marker id="acli-arch-arrow" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto"><path class="dd-head" d="M1 1 L7 4 L1 7"/></marker>
</defs>
<rect class="dd-frame" width="800" height="400" rx="10"/>
<rect class="dd-zone" x="16" y="16" width="240" height="336" rx="8"/>
<text class="t-zone" x="36" y="44">调用方 · 任意脚本</text>
<rect class="dd-zone" x="272" y="16" width="248" height="336" rx="8"/>
<text class="t-zone" x="292" y="44">适配器包 · codex</text>
<rect class="dd-zone" x="536" y="16" width="248" height="336" rx="8"/>
<text class="t-zone" x="556" y="44">事件文件</text>
<line class="dd-edge" x1="240" y1="96" x2="264" y2="96"/>
<line class="dd-edge" x1="240" y1="154" x2="264" y2="154"/>
<line class="dd-edge" x1="240" y1="212" x2="264" y2="212"/>
<line class="dd-edge" x1="240" y1="270" x2="264" y2="270"/>
<line class="dd-edge" x1="264" y1="96" x2="264" y2="270"/>
<path class="dd-edge" d="M264 127 H286" marker-end="url(#acli-arch-arrow)"/>
<path class="dd-edge" d="M504 248 H550" marker-end="url(#acli-arch-arrow)"/>
<text class="t-note" x="528" y="240" text-anchor="middle">追加</text>
<path class="dd-edge" d="M720 312 V360 Q720 368 712 368 H144 Q136 368 136 360 V338" marker-end="url(#acli-arch-arrow)"/>
<text class="t-note" x="428" y="388" text-anchor="middle">tail -f 读取，按完整行数推进</text>
<rect class="dd-card" x="32" y="72" width="208" height="48" rx="6"/>
<text class="t-mono" x="44" y="92">$ harnessctl send</text>
<text class="t-note" x="228" y="92" text-anchor="end">发起</text>
<text class="t-val" x="44" y="110"><tspan class="t-acc">fix-tests.jsonl</tspan> "修复…"</text>
<rect class="dd-card" x="32" y="130" width="208" height="48" rx="6"/>
<text class="t-mono" x="44" y="150">$ harnessctl send</text>
<text class="t-note" x="228" y="150" text-anchor="end">插话</text>
<text class="t-val" x="44" y="168"><tspan class="t-acc">fix-tests.jsonl</tspan> "先别跑…"</text>
<rect class="dd-card" x="32" y="188" width="208" height="48" rx="6"/>
<text class="t-mono" x="44" y="208">$ harnessctl cancel</text>
<text class="t-note" x="228" y="208" text-anchor="end">打断</text>
<text class="t-val" x="44" y="226"><tspan class="t-acc">fix-tests.jsonl</tspan></text>
<rect class="dd-card" x="32" y="246" width="208" height="48" rx="6"/>
<text class="t-mono" x="44" y="266">$ harnessctl reply</text>
<text class="t-note" x="228" y="266" text-anchor="end">审批</text>
<text class="t-val" x="44" y="284"><tspan class="t-acc">fix-tests.jsonl</tspan> req-1 deny</text>
<rect class="dd-card" x="32" y="300" width="208" height="36" rx="6"/>
<text class="t-mono" x="44" y="323">$ tail -f <tspan class="t-acc">fix-tests.jsonl</tspan></text>
<rect class="dd-card" x="288" y="72" width="216" height="110" rx="6"/>
<path class="dd-card-head" d="M288.6 104 V78 Q288.6 72.6 294 72.6 H498 Q503.4 72.6 503.4 78 V104 Z"/>
<line class="dd-edge" x1="288" y1="104" x2="504" y2="104"/>
<text class="t-name" x="304" y="93">包内容</text>
<text class="t-note" x="488" y="93" text-anchor="end">24 KB</text>
<line class="dd-hair" x1="304" y1="130" x2="488" y2="130"/>
<line class="dd-hair" x1="304" y1="156" x2="488" y2="156"/>
<text class="t-mono" x="304" y="121">harnessctl</text>
<text class="t-note" x="488" y="121" text-anchor="end">入口</text>
<text class="t-mono" x="304" y="147">manifest.json</text>
<text class="t-note" x="488" y="147" text-anchor="end">版本、依赖</text>
<text class="t-mono" x="304" y="173">lib/runtime.py</text>
<text class="t-note" x="488" y="173" text-anchor="end">运行时</text>
<rect class="dd-derived" x="288" y="200" width="216" height="96" rx="6"/>
<text class="t-val" x="304" y="226">运行进程（按需启动）</text>
<text class="t-val" x="304" y="250">→ codex app-server</text>
<text class="t-val" x="304" y="274">↻ 空闲回收，下次恢复</text>
<text class="t-say" x="292" y="322">虚线：适配器内部怎么做，</text>
<text class="t-say" x="292" y="342">协议不规定。</text>
<rect class="dd-card" x="552" y="72" width="216" height="240" rx="6"/>
<path class="dd-card-head" d="M552.6 104 V78 Q552.6 72.6 558 72.6 H762 Q767.4 72.6 767.4 78 V104 Z"/>
<line class="dd-edge" x1="552" y1="104" x2="768" y2="104"/>
<text class="t-val" x="568" y="93"><tspan class="t-acc">fix-tests.jsonl</tspan></text>
<text class="t-note" x="752" y="93" text-anchor="end">只追加</text>
<line class="dd-hair" x1="568" y1="130" x2="752" y2="130"/>
<line class="dd-hair" x1="568" y1="156" x2="752" y2="156"/>
<line class="dd-hair" x1="568" y1="182" x2="752" y2="182"/>
<line class="dd-hair" x1="568" y1="208" x2="752" y2="208"/>
<line class="dd-hair" x1="568" y1="234" x2="752" y2="234"/>
<line class="dd-hair" x1="568" y1="260" x2="752" y2="260"/>
<line class="dd-hair" x1="568" y1="286" x2="752" y2="286"/>
<text class="t-small" x="568" y="121">accepted · R1 · in-1</text>
<text class="t-small" x="568" y="147">accepted · R1 · in-2</text>
<text class="t-small" x="568" y="173">log · text.delta</text>
<text class="t-small" x="568" y="199">request · pending</text>
<text class="t-small" x="568" y="225">request · replied</text>
<text class="t-small" x="568" y="251">message</text>
<text class="t-small" x="568" y="277">result · R1 · completed</text>
<text class="t-note" x="568" y="303">…</text>
<text class="t-say" x="556" y="336">调用方只读这一个文件。</text>
</svg>
</div>
<figcaption>图 1 · 每条命令都带着同一个文件路径，agent 的一切输出都追加进这个文件；适配器内部怎么保活，协议不管</figcaption>
</figure>

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

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

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

---

## 三、插话语义的差异

### Claude Code：消息上的 priority

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

打断走另一条线：写一行 `type` 为 `control_request`、子类型为 `interrupt` 的消息，Claude Code 回一个 `control_response`[^cc-sdk-py]。

### Codex：带前置条件的 turn/steer

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

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

```text
→ {"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"}
```

### 同一句话在两条线上

<figure class="dd dd-figure">
<div class="dd-scroll">
<svg class="dd-svg" viewBox="0 0 800 448" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="acli-wire-title acli-wire-desc">
<title id="acli-wire-title">同一句插话在两家线上的报文</title>
<desc id="acli-wire-desc">左边是 Claude Code 的 stdin 和 stdout：写入一条带 next 优先级的用户消息“先别跑集成测试”后，没有直接回应，之后才看到工具结果、助手回答和 result；打断用 control_request interrupt，收到 control_response。右边是 Codex 的 JSON-RPC：turn/steer 带 expectedTurnId turn_7，立刻得到 turnId turn_7 的响应；打断用 turn/interrupt，响应为空对象。</desc>
<rect class="dd-frame" width="800" height="448" rx="10"/>
<rect class="dd-zone" x="16" y="16" width="376" height="416" rx="8"/>
<text class="t-zone" x="36" y="44">Claude Code · claude -p</text>
<rect class="dd-zone" x="408" y="16" width="376" height="416" rx="8"/>
<text class="t-zone" x="428" y="44">Codex · codex app-server</text>
<rect class="dd-card" x="32" y="64" width="344" height="292" rx="6"/>
<path class="dd-card-head" d="M32.6 96 V70 Q32.6 64.6 38 64.6 H370 Q375.4 64.6 375.4 70 V96 Z"/>
<line class="dd-edge" x1="32" y1="96" x2="376" y2="96"/>
<text class="t-name" x="48" y="85">stdin → / ← stdout</text>
<text class="t-note" x="360" y="85" text-anchor="end">NDJSON</text>
<line class="dd-hair" x1="48" y1="122" x2="360" y2="122"/>
<line class="dd-hair" x1="48" y1="148" x2="360" y2="148"/>
<line class="dd-hair" x1="48" y1="174" x2="360" y2="174"/>
<line class="dd-hair" x1="48" y1="226" x2="360" y2="226"/>
<line class="dd-hair" x1="48" y1="252" x2="360" y2="252"/>
<line class="dd-hair" x1="48" y1="278" x2="360" y2="278"/>
<line class="dd-hair" x1="48" y1="304" x2="360" y2="304"/>
<line class="dd-hair" x1="48" y1="330" x2="360" y2="330"/>
<text class="t-mono" x="48" y="113">→</text><text class="t-mono" x="66" y="113">user</text><text class="t-val" x="196" y="113">"修复失败的测试"</text>
<text class="t-mono" x="48" y="139">←</text><text class="t-mono" x="66" y="139">system/init</text><text class="t-val" x="196" y="139">session_id 9c1e…</text>
<text class="t-mono" x="48" y="165">←</text><text class="t-mono" x="66" y="165">assistant</text><text class="t-val" x="196" y="165">tool_use Bash</text>
<text class="t-mono" x="48" y="191">→</text><text class="t-mono" x="66" y="191">user</text><text class="t-val" x="196" y="191"><tspan class="t-acc">"先别跑集成测试"</tspan> · next</text>
<rect class="dd-derived" x="44" y="203" width="320" height="20" rx="4"/>
<text class="t-note" x="196" y="217"><tspan class="t-acc">（没有直接回应）</tspan></text>
<text class="t-mono" x="48" y="243">←</text><text class="t-mono" x="66" y="243">user</text><text class="t-val" x="196" y="243">tool_result · pytest</text>
<text class="t-mono" x="48" y="269">←</text><text class="t-mono" x="66" y="269">assistant</text><text class="t-val" x="196" y="269">"好，先只修单元测试"</text>
<text class="t-mono" x="48" y="295">←</text><text class="t-mono" x="66" y="295">result</text><text class="t-val" x="196" y="295">subtype success</text>
<text class="t-mono" x="48" y="321">→</text><text class="t-mono" x="66" y="321">control_request</text><text class="t-val" x="196" y="321">subtype interrupt</text>
<text class="t-mono" x="48" y="347">←</text><text class="t-mono" x="66" y="347">control_response</text><text class="t-val" x="196" y="347">subtype success</text>
<rect class="dd-card" x="424" y="64" width="344" height="292" rx="6"/>
<path class="dd-card-head" d="M424.6 96 V70 Q424.6 64.6 430 64.6 H762 Q767.4 64.6 767.4 70 V96 Z"/>
<line class="dd-edge" x1="424" y1="96" x2="768" y2="96"/>
<text class="t-name" x="440" y="85">请求 → / ← 响应与通知</text>
<text class="t-note" x="752" y="85" text-anchor="end">JSON-RPC</text>
<line class="dd-hair" x1="440" y1="122" x2="752" y2="122"/>
<line class="dd-hair" x1="440" y1="148" x2="752" y2="148"/>
<line class="dd-hair" x1="440" y1="174" x2="752" y2="174"/>
<line class="dd-hair" x1="440" y1="200" x2="752" y2="200"/>
<line class="dd-hair" x1="440" y1="226" x2="752" y2="226"/>
<line class="dd-hair" x1="440" y1="252" x2="752" y2="252"/>
<line class="dd-hair" x1="440" y1="278" x2="752" y2="278"/>
<line class="dd-hair" x1="440" y1="304" x2="752" y2="304"/>
<line class="dd-hair" x1="440" y1="330" x2="752" y2="330"/>
<text class="t-mono" x="440" y="113">→</text><text class="t-mono" x="458" y="113">turn/start</text><text class="t-val" x="584" y="113">"修复失败的测试"</text>
<text class="t-mono" x="440" y="139">←</text><text class="t-mono" x="458" y="139">turn/started</text><text class="t-val" x="584" y="139">turn.id turn_7</text>
<text class="t-mono" x="440" y="165">←</text><text class="t-mono" x="458" y="165">item/started</text><text class="t-val" x="584" y="165">commandExecution</text>
<text class="t-mono" x="440" y="191">→</text><text class="t-mono" x="458" y="191">turn/steer</text><text class="t-val" x="584" y="191">turn_7 <tspan class="t-acc">"先别跑集成测试"</tspan></text>
<text class="t-mono" x="440" y="217">←</text><text class="t-mono" x="458" y="217">id:3 result</text><text class="t-val" x="584" y="217"><tspan class="t-acc">turnId turn_7</tspan></text>
<text class="t-mono" x="440" y="243">←</text><text class="t-mono" x="458" y="243">item/completed</text><text class="t-val" x="584" y="243">commandExecution</text>
<text class="t-mono" x="440" y="269">←</text><text class="t-mono" x="458" y="269">item/completed</text><text class="t-val" x="584" y="269">"好，先只修单元测试"</text>
<text class="t-mono" x="440" y="295">←</text><text class="t-mono" x="458" y="295">turn/completed</text><text class="t-val" x="584" y="295">status completed</text>
<text class="t-mono" x="440" y="321">→</text><text class="t-mono" x="458" y="321">turn/interrupt</text><text class="t-val" x="584" y="321">turn_7</text>
<text class="t-mono" x="440" y="347">←</text><text class="t-mono" x="458" y="347">id:4 result</text><text class="t-val" x="584" y="347">{}</text>
<text class="t-say" x="48" y="388">虚线：Claude Code 不回应这条消息，</text>
<text class="t-say" x="48" y="408">它落在哪一轮只能看后面的事件。</text>
<text class="t-say" x="440" y="388">Codex 当场回执；turn_7 若已结束，</text>
<text class="t-say" x="440" y="408">这条请求直接报错。</text>
</svg>
</div>
<figcaption>图 2 · 同一句插话，Codex 当场告诉你它进了哪一轮，Claude Code 要从后续事件里推断（报文按官方文档和 schema 简写，不是抓包）</figcaption>
</figure>

把图 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` 打断[^acp-prompt]。Claude Code 和 Codex 都有 ACP 适配器，codex-acp 内部启动的就是 Codex 的 app-server。

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

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

### acpx：ACP 的命令行客户端

acpx 补的正是这一层。它是 ACP 的无头命令行客户端，内置了 `claude`、`codex` 等几十个 agent 的配置[^acpx]：

```bash
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”[^acpx-prompting]。给 acpx 加 `steer` 子命令的 issue 还开着[^acpx-836]。

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

### 厂商自己的守护进程

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

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

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

### 其他方案

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

<figure class="dd dd-figure dt-figure">
<div class="dt-panel">
<div class="dt-title" id="acli-survey-title">谁能用一条命令做什么</div>
<div class="dt-scroll">
<table class="dt dt-sticky" style="min-width:640px" aria-labelledby="acli-survey-title">
<colgroup><col style="width:20%"><col><col><col><col class="hl"></colgroup>
<thead>
<tr><th scope="col">方案</th><th scope="col">覆盖两家</th><th scope="col">一条命令发起</th><th scope="col">一条命令打断</th><th scope="col" class="acc">一条命令插进当前一轮</th></tr>
</thead>
<tbody>
<tr><th scope="row">ACP 协议本身</th><td class="good">是，经适配器</td><td colspan="2">否，需要常驻连接</td><td class="warn">适配器扩展 <code>_session/steering</code>，未标准化</td></tr>
<tr><th scope="row">acpx</th><td class="good">是</td><td class="good">是</td><td class="good">是，<code>cancel</code></td><td class="bad">否，<code>--no-wait</code> 只排到下一轮</td></tr>
<tr><th scope="row">Claude Code 后台会话</th><td class="bad">只有 Claude Code</td><td class="good">是，<code>--bg</code></td><td class="warn">只有 <code>stop</code>，停掉整个会话</td><td class="bad">否，<code>--resume</code> 发的是下一轮，且拒绝脚本调用</td></tr>
<tr><th scope="row">Codex app-server daemon</th><td class="bad">只有 Codex</td><td colspan="2">否，要经 proxy 说完整的 JSON-RPC</td><td class="warn">协议里有 <code>turn/steer</code>，但不是一条命令</td></tr>
<tr><th scope="row">coder/agentapi</th><td class="good">是</td><td class="good">是，HTTP</td><td colspan="2" class="warn">靠发按键，取决于各家 TUI；已停止维护</td></tr>
<tr><th scope="row">A2A / AG-UI / MCP</th><td colspan="4">不是驱动编码 agent 会话的形态</td></tr>
</tbody>
</table>
</div>
<p class="dt-note">核实于 2026-11-01。“一条命令”指一次性的 shell 调用，不需要调用方自己维持连接。</p>
</div>
<figcaption>表 1 · 最右一列没有一个“是”：发起和打断都有人做了，插进当前一轮的那条命令还没有</figcaption>
</figure>

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

**那就自己设计一个。**

---

## 五、协议的设计

我们设计的这个协议叫 harnessctl，实现开源在 [AgentUse](https://github.com/memory-co/agentuse/tree/91bb42d82160d226c7f5337565879ffcf384685b/harnessctl) 仓库[^hc-readme]。设计时只守一条原则：**调用方只说自己要什么，怎么做到都是适配器的事。** 落下来就是三样东西：四条命令、一个事件文件、一个独立的适配器包。

### 四条命令

```text
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 转义：

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

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

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

### 事件文件的路径就是会话

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

### send：由适配器决定投递

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

<figure class="dd dd-figure">
<div class="dd-scroll">
<svg class="dd-svg" viewBox="0 0 800 432" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="acli-delivery-title acli-delivery-desc">
<title id="acli-delivery-title">同一条 send 在两个适配器的事件文件里</title>
<desc id="acli-delivery-desc">两个适配器都收到两条 send。Codex 适配器在内部发 turn/steer，拿到原生回执后写 accepted，第二句话 in-2 和第一句 in-1 同属 run R1，一个 result 同时覆盖两句；如果原生回复这一轮已结束，in-2 改属新的 run R2。Claude Code 适配器没有可靠回执，把 in-2 排进内部队列，分配 R2，R1 结束后才提交，两个 run 各有一个 result。</desc>
<rect class="dd-frame" width="800" height="432" rx="10"/>
<rect class="dd-zone" x="16" y="16" width="376" height="400" rx="8"/>
<text class="t-zone" x="36" y="44">Codex 适配器 · 有原生回执</text>
<rect class="dd-zone" x="408" y="16" width="376" height="400" rx="8"/>
<text class="t-zone" x="428" y="44">Claude Code 适配器 · 没有轮次回执</text>
<rect class="dd-card" x="32" y="64" width="344" height="292" rx="6"/>
<path class="dd-card-head" d="M32.6 96 V70 Q32.6 64.6 38 64.6 H370 Q375.4 64.6 375.4 70 V96 Z"/>
<line class="dd-edge" x1="32" y1="96" x2="376" y2="96"/>
<text class="t-name" x="48" y="85">命令与事件，从上往下</text>
<text class="t-note" x="360" y="85" text-anchor="end">fix-tests.jsonl</text>
<line class="dd-hair" x1="48" y1="122" x2="360" y2="122"/>
<line class="dd-hair" x1="48" y1="148" x2="360" y2="148"/>
<line class="dd-hair" x1="48" y1="200" x2="360" y2="200"/>
<line class="dd-hair" x1="48" y1="226" x2="360" y2="226"/>
<line class="dd-hair" x1="48" y1="252" x2="360" y2="252"/>
<line class="dd-hair" x1="48" y1="304" x2="360" y2="304"/>
<line class="dd-hair" x1="48" y1="330" x2="360" y2="330"/>
<text class="t-mono" x="48" y="113">$ send</text><text class="t-val" x="136" y="113">"修复测试"</text>
<text class="t-mono" x="48" y="139">accepted</text><text class="t-val" x="136" y="139">R1 · in-1</text>
<text class="t-mono" x="48" y="165">$ send</text><text class="t-val" x="136" y="165">"先别跑集成测试"</text>
<rect class="dd-derived" x="44" y="175" width="320" height="22" rx="4"/>
<text class="t-note" x="56" y="190">内部 turn/steer → result.turnId</text>
<text class="t-mono" x="48" y="217">accepted</text><text class="t-val" x="136" y="217"><tspan class="t-acc">R1</tspan> · in-2</text>
<text class="t-mono" x="48" y="243">message</text><text class="t-val" x="136" y="243">"已修复单元测试"</text>
<text class="t-mono" x="48" y="269">result</text><text class="t-val" x="136" y="269"><tspan class="t-acc">R1</tspan> · [in-1, in-2]</text>
<rect class="dd-derived" x="44" y="279" width="320" height="22" rx="4"/>
<text class="t-note" x="56" y="294">或者：原生回 no active turn，这一轮刚结束</text>
<text class="t-mono" x="48" y="321">accepted</text><text class="t-val" x="136" y="321"><tspan class="t-acc">R2</tspan> · in-2</text>
<text class="t-mono" x="48" y="347">result</text><text class="t-val" x="136" y="347"><tspan class="t-acc">R2</tspan> · [in-2]</text>
<rect class="dd-card" x="424" y="64" width="344" height="292" rx="6"/>
<path class="dd-card-head" d="M424.6 96 V70 Q424.6 64.6 430 64.6 H762 Q767.4 64.6 767.4 70 V96 Z"/>
<line class="dd-edge" x1="424" y1="96" x2="768" y2="96"/>
<text class="t-name" x="440" y="85">命令与事件，从上往下</text>
<text class="t-note" x="752" y="85" text-anchor="end">fix-tests.jsonl</text>
<line class="dd-hair" x1="440" y1="122" x2="752" y2="122"/>
<line class="dd-hair" x1="440" y1="148" x2="752" y2="148"/>
<line class="dd-hair" x1="440" y1="200" x2="752" y2="200"/>
<line class="dd-hair" x1="440" y1="226" x2="752" y2="226"/>
<line class="dd-hair" x1="440" y1="252" x2="752" y2="252"/>
<line class="dd-hair" x1="440" y1="304" x2="752" y2="304"/>
<line class="dd-hair" x1="440" y1="330" x2="752" y2="330"/>
<text class="t-mono" x="440" y="113">$ send</text><text class="t-val" x="528" y="113">"修复测试"</text>
<text class="t-mono" x="440" y="139">accepted</text><text class="t-val" x="528" y="139">R1 · in-1</text>
<text class="t-mono" x="440" y="165">$ send</text><text class="t-val" x="528" y="165">"先别跑集成测试"</text>
<rect class="dd-derived" x="436" y="175" width="320" height="22" rx="4"/>
<text class="t-note" x="448" y="190">忙：证明不了归属，放进适配器的队列</text>
<text class="t-mono" x="440" y="217">accepted</text><text class="t-val" x="528" y="217"><tspan class="t-acc">R2</tspan> · in-2</text>
<text class="t-mono" x="440" y="243">message</text><text class="t-val" x="528" y="243">"已修复测试"</text>
<text class="t-mono" x="440" y="269">result</text><text class="t-val" x="528" y="269">R1 · [in-1]</text>
<rect class="dd-derived" x="436" y="279" width="320" height="22" rx="4"/>
<text class="t-note" x="448" y="294">R1 结束后，才把 in-2 写进 stream-json</text>
<text class="t-mono" x="440" y="321">message</text><text class="t-val" x="528" y="321">"好，只修单元测试"</text>
<text class="t-mono" x="440" y="347">result</text><text class="t-val" x="528" y="347"><tspan class="t-acc">R2</tspan> · [in-2]</text>
<text class="t-say" x="48" y="384">虚线：适配器内部发生的事，不进关键事件。</text>
<text class="t-say" x="48" y="404">R1、R2 代表适配器分配的 UUID；两侧的调用方写的命令完全相同。</text>
</svg>
</div>
<figcaption>图 3 · 调用方不选投递方式：Codex 适配器拿到回执才公布归属，Claude Code 适配器证明不了归属，就排到下一个 run（Codex 的 run 归属来自实测，正文内容和 Claude Code 一侧按代码流程示意）</figcaption>
</figure>

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

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

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

### 事件：能不能丢

事件只有五种，按能不能丢分成两层[^hc-event]：

<figure class="dd dd-figure dt-figure">
<div class="dt-panel">
<div class="dt-title" id="acli-events-title">五种事件</div>
<div class="dt-scroll">
<table class="dt" aria-labelledby="acli-events-title">
<colgroup><col style="width:16%"><col><col style="width:36%"></colgroup>
<thead>
<tr><th scope="col">类型</th><th scope="col">内容</th><th scope="col">可靠性</th></tr>
</thead>
<tbody>
<tr><th scope="row" class="mono">accepted</th><td>完整输入、<code>input_id</code>、确认的 <code>run_id</code></td><td>校验、加锁、fsync 之后，<code>send</code> 才返回成功</td></tr>
<tr><th scope="row" class="mono">message</th><td>完整正文快照，同一 <code>message_id</code> 整条替换</td><td rowspan="3">关键，校验、加锁、fsync，不能靠丢日志丢弃</td></tr>
<tr><th scope="row" class="mono">request</th><td>审批或提问的 <code>pending</code>、<code>replied</code>、<code>expired</code> 快照</td></tr>
<tr><th scope="row" class="mono">result</th><td>一个 run 的终态（<code>completed</code>、<code>cancelled</code>、<code>failed</code>）、<code>input_ids</code>、输出和计量</td></tr>
<tr><th scope="row" class="mono">log</th><td>流式片段、工具进度、原生回执、进程诊断</td><td class="warn">可丢、可合并、可限流，写失败不影响工作结果</td></tr>
</tbody>
</table>
</div>
</div>
<figcaption>表 2 · 前四种足够重建全部业务状态，所以第五种可以随便丢</figcaption>
</figure>

分层的标准是：**把所有 `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？"}
```

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

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

### 到两家原生调用的映射

<figure class="dd dd-figure dt-figure">
<div class="dt-panel">
<div class="dt-title" id="acli-map-title">harnessctl 命令到两家原生调用</div>
<div class="dt-scroll">
<table class="dt dt-sticky" style="min-width:640px" aria-labelledby="acli-map-title">
<colgroup><col style="width:18%"><col><col></colgroup>
<thead>
<tr><th scope="col">harnessctl</th><th scope="col">Claude Code · stream-json</th><th scope="col">Codex · app-server</th></tr>
</thead>
<tbody>
<tr class="dt-group"><th colspan="3" scope="rowgroup">send</th></tr>
<tr><th scope="row">首次</th><td><code>claude -p --input-format stream-json --output-format stream-json --verbose --replay-user-messages --permission-prompt-tool stdio</code>，写一条用户消息</td><td><code>codex app-server --listen stdio://</code>，<code>initialize</code>、<code>thread/start</code>、<code>turn/start</code></td></tr>
<tr><th scope="row">回收后续上</th><td>同上，加 <code>--resume &lt;session&gt;</code></td><td><code>thread/resume</code>、<code>turn/start</code></td></tr>
<tr><th scope="row" class="acc">正在忙</th><td>适配器内部排队，这一轮结束后再写用户消息</td><td><code>turn/steer</code> 带 <code>expectedTurnId</code>；这一轮刚结束就改为下一轮</td></tr>
<tr class="dt-group"><th colspan="3" scope="rowgroup">其他</th></tr>
<tr><th scope="row" class="mono">cancel</th><td><code>control_request</code> · <code>interrupt</code></td><td><code>turn/interrupt</code>；还没拿到 turn id 时先记下，拿到后再发</td></tr>
<tr><th scope="row">reply · 审批</th><td><code>control_response</code>，<code>behavior: allow / deny</code></td><td>JSON-RPC 响应，<code>decision: accept / decline</code></td></tr>
<tr><th scope="row">reply · 提问</th><td><code>AskUserQuestion</code> 的 <code>updatedInput.answers</code></td><td><code>item/tool/requestUserInput</code> 的 <code>answers</code></td></tr>
</tbody>
</table>
</div>
<p class="dt-note"><code>status</code> 只查适配器自己的状态，不需要映射。</p>
</div>
<figcaption>表 3 · 每条命令在两家都找得到对应的原生调用，差异全部收在适配器里（读自两个后端模块）</figcaption>
</figure>

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

---

## 六、参考实现：运行进程

### 按需启动，空闲回收

答案是每个地址一个**运行进程**，由 `send` 按需拉起。它持有原生连接、队列和待回复请求[^hc-runtime]：

<figure class="dd dd-figure">
<div class="dd-scroll">
<svg class="dd-svg" viewBox="0 0 800 384" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="acli-pipe-title acli-pipe-desc">
<title id="acli-pipe-title">一句插话在 Codex 适配器内部走过的路</title>
<desc id="acli-pipe-desc">harnessctl send 经本机 IPC 把话交给运行进程；运行进程向 codex app-server 发 turn/steer，收到 result.turnId 后，把 accepted 校验、加锁、fsync 写进 fix-tests.jsonl，send 这才以 0 退出。恢复标识、绑定配置和运行锁放在旁边的 fix-tests.jsonl.d 目录。调用方用 tail -f 读事件文件。</desc>
<defs>
<marker id="acli-pipe-arrow" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto"><path class="dd-head" d="M1 1 L7 4 L1 7"/></marker>
<marker id="acli-pipe-arrow-acc" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto"><path class="dd-head-acc" d="M1 1 L7 4 L1 7"/></marker>
</defs>
<rect class="dd-frame" width="800" height="384" rx="10"/>
<rect class="dd-zone" x="16" y="16" width="768" height="352" rx="8"/>
<text class="t-zone" x="36" y="44">适配器内部 · 一个事件文件地址</text>
<rect class="dd-derived" x="244" y="64" width="248" height="116" rx="6"/>
<path class="dd-edge-acc" d="M184 104 H258" marker-end="url(#acli-pipe-arrow-acc)"/>
<text class="t-note" x="214" y="96" text-anchor="middle">本机 IPC</text>
<path class="dd-edge" d="M258 128 H186" marker-end="url(#acli-pipe-arrow)"/>
<text class="t-note" x="214" y="142" text-anchor="middle">退出码 0</text>
<path class="dd-edge-acc" d="M476 108 H638" marker-end="url(#acli-pipe-arrow-acc)"/>
<text class="t-small" x="560" y="100" text-anchor="middle"><tspan class="t-acc">turn/steer</tspan></text>
<path class="dd-edge-acc" d="M640 128 H478" marker-end="url(#acli-pipe-arrow-acc)"/>
<text class="t-small" x="560" y="142" text-anchor="middle"><tspan class="t-acc">result.turnId</tspan></text>
<path class="dd-edge-acc" d="M340 164 V230" marker-end="url(#acli-pipe-arrow-acc)"/>
<text class="t-note" x="330" y="208" text-anchor="end">校验 → 加锁 → fsync</text>
<path class="dd-edge" d="M240 274 H182" marker-end="url(#acli-pipe-arrow)"/>
<path class="dd-edge" d="M468 164 V196 Q468 204 476 204 H632 Q640 204 640 212 V230" marker-end="url(#acli-pipe-arrow)"/>
<rect class="dd-card" x="32" y="72" width="152" height="64" rx="6"/>
<text class="t-mono" x="44" y="96">$ harnessctl send</text>
<text class="t-val" x="44" y="120">"先别跑集成测试"</text>
<rect class="dd-card" x="260" y="92" width="216" height="72" rx="6"/>
<text class="t-mono" x="276" y="114">lib/runtime.py _worker</text>
<text class="t-note" x="276" y="134">队列 · run_id · 待回复请求</text>
<text class="t-note" x="276" y="154">原生连接 · 完整正文</text>
<text class="t-note" x="252" y="84">运行进程 · 按需启动，空闲回收</text>
<rect class="dd-card" x="640" y="92" width="128" height="52" rx="6"/>
<text class="t-mono" x="652" y="114">codex</text>
<text class="t-mono" x="652" y="132">app-server</text>
<rect class="dd-card" x="240" y="232" width="272" height="88" rx="6"/>
<path class="dd-card-head" d="M240.6 264 V238 Q240.6 232.6 246 232.6 H506 Q511.4 232.6 511.4 238 V264 Z"/>
<line class="dd-edge" x1="240" y1="264" x2="512" y2="264"/>
<text class="t-val" x="256" y="253">fix-tests.jsonl</text>
<text class="t-note" x="496" y="253" text-anchor="end">公开</text>
<line class="dd-hair" x1="256" y1="290" x2="496" y2="290"/>
<text class="t-small" x="256" y="281"><tspan class="t-acc">accepted · R1 · in-2</tspan></text>
<text class="t-small" x="256" y="307">…</text>
<rect class="dd-card" x="32" y="256" width="148" height="36" rx="6"/>
<text class="t-mono" x="44" y="279">$ tail -f</text>
<rect class="dd-card" x="528" y="232" width="240" height="88" rx="6"/>
<path class="dd-card-head" d="M528.6 256 V238 Q528.6 232.6 534 232.6 H762 Q767.4 232.6 767.4 238 V256 Z"/>
<line class="dd-edge" x1="528" y1="256" x2="768" y2="256"/>
<text class="t-val" x="544" y="249">fix-tests.jsonl.d/</text>
<text class="t-note" x="752" y="249" text-anchor="end">私有</text>
<text class="t-small" x="544" y="273">config.json   绑定 cwd、model</text>
<text class="t-small" x="544" y="291">native.json   原生恢复标识</text>
<text class="t-small" x="544" y="309">runtime.lock  失联检测</text>
<text class="t-say" x="36" y="346">虚线：运行进程属于实现，协议里没有它。send 等到原生回执才写 accepted，不等模型回答。</text>
</svg>
</div>
<figcaption>图 4 · 常驻的是一个按需启动的运行进程；插话的归属由原生回执确认之后，才写进事件文件</figcaption>
</figure>

命令和运行进程之间走本机 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 失败，但轮次的开始、插话、打断、回收和恢复都照常发生：

```console
$ 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（本机没有安装，这一侧来自读代码和假后端测试）。

### 拿来用

```bash
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：地址不存在，说明入口可用
```

---

## 七、回到起点

回到开头那两条命令：

```bash
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. **那个常驻进程去哪了？** 变成每个地址一个按需启动的运行进程，空闲回收，失联时不重放。它只是实现细节，协议里没有它。

## 参考资料
