一个抹平多 Agent CLI 协议差异的 AgentHost 设计漫谈
Co-authored by Claude Opus 4.7
最近几个月在做一个常驻在桌面端的守护进程:它对外扮演一个”被远程控制”的入口,远端的 bot、本机的 desktop、本机的 TUI 可以同时连进来,驱动同一台机器上跑着的 coding agent —— 不管这个 agent 是 claude code、codex、还是内部代号 hx 的 agent CLI。同一个 session 任务,A 端发起、B 端跟看、C 端续问,消息通过这个进程在多端之间保持同步与统一;机器在 NAT 后侧也好、断线重连也好,这层”对所有端表现一致”的抽象不破。

听起来像写一个 wrapper,做下来才发现,光是把三家各自一套的 stream-json、各自一套的 session 落盘格式、各自一套的 fork 语义抹平,再让多个 subscriber 同时订阅而互不踩脚,就够写一篇博客了。更烧脑的是边界问题:用户在终端 codex --resume 起的进程,跟我 host 自己 spawn 的子进程同时盯着一个 jsonl 文件,谁先写、谁该 fork、谁能 resume —— 这种”我看不见的并发”才是设计里真正难处理的地方。
这篇文章不打算复述设计文档,而是挑出几个我觉得最有意思的设计点:三层接口的分流、canonical event 的 bracket/delta 二分、subscriber 的 replay 策略、host-internal 的 follow-up 队列、还有用 grant token 把 loopback WS 锁在本机 process 里。
名词与定义
涉及内部代号的部分都已脱敏。这里登记一下文中的术语,避免歧义。
- AgentHost:本文讨论的桌面常驻守护进程,单实例。对外扮演”被远程控制”的入口,把多个 agent CLI 的协议差异抹平,把同一个 session 正在执行的事件流同步给多个 subscriber(远端 bot / 本机 desktop / 本机 TUI),保持多端消息一致。
- agent CLI:底层的 AI coding agent 命令行工具。本文涉及三类:claude(Anthropic)、codex(OpenAI),以及代号
hx的内部 agent CLI(脱敏占位)。 - canonical event:AgentHost 对外的统一事件协议,用
"k"字段判别事件类型的扁平 NDJSON。 - bracket / delta:canonical event 的两种类别。bracket 是阶段性事件(
run.end、text.end等),落 SQLite;delta 是流式中间帧(text.delta、tool.exec.update等),不落库。 - subscriber:消息订阅者,包括远端 server 和本机 process(desktop / TUI / CLI),1:1 对应一条 WS 链接。
- session 归属:某个 jsonl 文件是不是 AgentHost 自己写的。归属判定决定能不能原地 resume,还是要 fork 一份新的。
- 远端 relay:远端服务端,业务消息的最终目的地(脱敏占位,泛指任意远端 bot 服务)。
整体长这样
下面这张图来自设计文档,从下往上看是 agent 子进程 → 适配层 → Bus → 传输层 → 控制面 → 调用方:
CLI Tool / Desktop App (mgmt) Server (远端 relay) Desktop App
(agent-host ...) (本机 GUI)
│ │ │
│ IPC socket (short-lived req/resp) │ Remote WSS │ Loopback WS
│ addr: host.lock └─────────┬─────────┘
│ │
┌───────┼──────────────────────────────────────────────┼──────────────────┐
│ │ AgentHost (single instance, ~/.<host>/agent-host/host.lock) │
│ ▼ │ │
│ ┌──────────────────────────────────────────────────────────────────┐ │
│ │ IPC Control Handler (concurrent · each call short-lived) │ │
│ │ subscriber.{add, remove, list} → drives WS Transport Layer │ │
│ │ session.{list, create, activate, fork, delete} → Sess. Manager │ │
│ │ host.{status, shutdown} → host live state / Local DB │ │
│ └──────────────────────────────────────────────────────────────────┘ │
│ │ │
│ ┌───────────────────────────────────────────────────────────▼───────┐ │
│ │ WS Transport Layer -- shared message protocol -- │ │
│ │ |- Remote WS client <--> Server │ │
│ │ `- Loopback WS server <--> Desktop / CLI / TUI (127.0.0.1) │ │
│ └───────────────────────────────┬───────────────────────────────────┘ │
│ │ ▲ │
│ inbound │ │ outbound │
│ ▼ │ │
│ ┌─────────────────────────────────────────────────────────────────┐ │
│ │ Pub/Sub Bus (Subscriber table + per-subscriber buf <=1k) │ │
│ │ in : remote/local -> serial per-session -> Session Manager │ │
│ │ out: session events -> fan-out to all subscribers │ │
│ └──────────────────────────────┬──────────────────────────────────┘ │
│ │ ▲ │
│ ▼ │ │
│ ┌────────────────────────────────────────────────┐ ┌──────────────┐ │
│ │ Unified Message Protocol / Session Manager │ │ Local DB │ │
│ │ text / tool_call / tool_result / │◄►│ (SQLite) │ │
│ │ permission_request / usage / done / error │ │ - sessions │ │
│ └──────────────────┬──────────────────┬──────────┘ │ - messages │ │
│ │ │ │ - agents │ │
│ ┌────────▼──────┐ ┌────────▼──────────┐ └──────────────┘ │
│ │ CLIAdapter │ │ SDKAdapter │ │
│ │ stdin/stdout │ │ in-process │ │
│ │ stream-json │ │ AsyncIterable │ │
│ └────────┬──────┘ └────────┬──────────┘ │
│ │ │ │
│ ┌──────────────────┼──────┬───────────┼───────────┐ │
│ ▼ ▼ ▼ ▼ ▼ ▼ │
│ claude codex aider cc-sdk codex-sdk │
│ (CLI) (CLI) (CLI) (lib) (lib) │
└─────────────────────────────────────────────────────────────────────────┘
可以看到几个分界面:
- 左上 IPC 是控制面:本机其他 process 通过短连接 Unix socket 跟 host 喊话,用来驱动 session / subscriber / host 的生命周期。
- 右上 WS 是业务面:长连接,承载用户消息和 agent 流式回复。一期就同时有两条入口 —— 远端 client 主动 dial 出去(笔记本在 NAT 后面,没办法被动接),以及本机 loopback server 等本地 process 拨号进来。
- 中间 Bus 是核心:所有入向消息先 per-session 串行落 SQLite,落库那一刻分配
sequence_id(autoincrement rowid),再 fan-out 给所有 subscriber。SQLite 是事实源 —— 任何 subscriber 断线,都按各自的游标从这个数据库里拉增量。 - 下面 Adapter 抹差异:每个 agent CLI 各自一套 stream-json 协议,adapter 把它们都翻译成 canonical event 才丢给 Bus。
设计点 1:三层接口怎么分
最早讨论的时候,自然会想画三套 API:CLI 一套、IPC 一套、WS 一套。但很快发现一个尴尬的点:CLI 和 IPC 的指令 90% 是重叠的 —— 用户终端敲 agent-host session.create,跟 desktop fork 一个 agent-host session.create 出来调用,凭什么要走两套不同的解析?
最后定下来的方案是 同一套命令信封协议:
# 终端用户敲
agent-host '{"command":"session.create","params":{"agent_type":"claude"}}'
# desktop fork 调用一模一样
agent-host '{"command":"session.create","params":{"agent_type":"claude"}}'
输入永远是 {command, params},输出永远是 {code, message, data}。但 host 内部按命令做三种分流:
| 分流 | 命令 | 行为 |
|---|---|---|
local |
host.start / agent.list 等 |
直接读写本地文件,host 没起来也能跑 |
ipc |
session.* / subscriber.* |
转 Unix socket 短连接到活体 host |
hybrid |
host.status / host.shutdown |
先试 IPC 拿实时态,连不上降级读 lock |
调用方完全不用关心哪条命令是本地直执还是绕了一道 socket。这套分流的另一个收益:host.start 这种 host 还没起来时就要用的命令,跟 session.create 这种必须 host 在跑才有意义的命令,可以共享同一个二进制、同一种调用形态。把”我现在能不能拿到 host”这个问题压到了路由表里,对外抹平。
至于 WS 这层,因为它承载的是高吞吐流式消息,硬塞进短连接的命令信封显然不合适,所以它是另一套 pb wire(按 CmdId 分发)。但有意思的是,WS 上跑的协议对远端和本机 loopback 是同一份 —— 远端 bot 看到的 RelayMessageReq,跟本机 desktop 看到的 RelayMessageReq 一模一样。这意味着 desktop 端的渲染器可以直接拿过去给远端用,反过来也行。
设计点 2:canonical event 的 bracket / delta 二分
抹平多个 agent CLI 的 stream-json,最直观的做法是定义一份”超集事件表”,把 claude 有的、codex 有的、hx 有的一股脑都列出来。我们最早就是这么干的,结果事件表越列越长,每加一个新 agent 就要改一次。
后来重构成了现在这套:用一个 "k" 字段判别事件类型,扁平字段平铺。比如:
{"k":"run.start", "runId":"r1", "capabilities":{"model":"claude-opus"}}
{"k":"text.delta", "runId":"r1", "contentIndex":0, "text":"hel"}
{"k":"text.delta", "runId":"r1", "contentIndex":0, "text":"lo"}
{"k":"text.end", "runId":"r1", "contentIndex":0, "text":"hello"}
{"k":"run.end", "runId":"r1", "state":"completed", "usage":{...}}
这套设计里最有意思的是bracket vs delta 的二分:
- bracket 事件(
*.start/*.end/run.*/turn.*/tool.exec.end等)是阶段性的、不可变的、有意义的 checkpoint。它们落 SQLite,分配sequence_id。 - delta 事件(
text.delta/thinking.delta/tool.call.delta/tool.exec.update/artifact.delta这 5 种)是流式中间帧。它们不落库,但仍然 fan-out 给开了forward_deltas的 live subscriber。
为什么 delta 不落库?因为 bracket 已经携带了最终状态 —— text.end 里有完整的 text,tool.exec.end 里有最终的 stdout/stderr。把 delta 也存下来,回放历史时就要在”重新动画一次输入”和”直接给最终态”之间二选一,多数 UI 只关心后者。所以历史回放只走 bracket,节省 80%+ 的存储。
但 cursor 必须连续 —— delta 在 fan-out 时仍然占用一个虚拟的 sequence_id(复用最近那个 bracket 的 seq),保证 subscriber 重连时 WHERE sequence_id > cursor 不会跳号到一个根本没存的 delta 上。
代码里大概长这样:
const skipPersistence = isDeltaEvent(ev.k);
let seq: number;
if (skipPersistence) {
seq = this.bracketSeq; // 复用最近的 bracket 序号,不落库
} else {
seq = messagesRepo.insert({ // 落库,autoincrement 分配新 seq
/* ... */
});
this.bracketSeq = seq;
}
// 不论 bracket 还是 delta,都 fan-out
fanOut({ message_id, sequence_id: seq, event: ev });
这个设计的另一个好处:delta 是可丢的。subscriber 缓冲区满了,丢掉中间几帧 delta 不影响最终一致性 —— 下一个 bracket 一来,状态就对齐了。
设计点 3:subscriber 自带”补传策略”
系统里另一个有趣的地方是 subscriber 模型。每个 subscriber(不管是远端 bot 还是本机 desktop)有几个独立的字段,决定它怎么收消息:
forward_deltas(默认false):是否投递 delta 中间帧。大多数 UI 关心 turn 完成态,关掉省带宽;自带 streaming 渲染的桌面端打开,享受逐字效果。replay_mode:握手补传策略,三档:none:跳过补传,由首条 live deliver 自然推进 cursor。适合纯 web app,让 client 走list_history_sessions自己懒加载。since_cursor:全量补sequence_id > cursor的所有 bracket。等价于”绝不丢历史”,适合桌面 / TUI。max_count(默认):只补 cursor 之后的最新 N 条(默认 200),早期 backlog 丢就丢。
most_recent_sequence_id:游标本身。
之所以三档,是因为不同 client 的 trade-off 完全不同:
- 桌面客户端 一般不能接受丢历史 —— 用户离开三天回来,期待看到完整的 turn 列表。所以默认应该是
since_cursor。 - 纯 web app 想要 cursor=0 的 fresh client 拿到整张 messages 表?那就太狠了 —— 桌面侧聊了几天的几千条记录全部一次灌过来,比直接调
list_history_sessionsAPI 还慢。所以推荐none,让前端自己懒加载。 - 远端 bot 介于两者之间 —— 短期断线(几分钟)希望续传,长期断线(几小时)的 backlog 不重要。
max_count刚好。
更关键的是这三档配置在 subscriber 表里跨重启持久。host 重启后,subscriber 重新拨进来,host 不会自作主张地把策略改回默认,而是按表里的配置走。这样不同种类的 client 接进来,host 不需要”识别 client 类型”也能用对策略。
设计点 4:fork 是冲突规避的统一出口
这个设计是我觉得最有意思的部分,因为它处理了一个看起来”分布式系统才会遇到”的问题,但其实只是单机进程间的暗中竞争。
底层 agent(无论 codex / claude / hx)的 session 都是裸的 append-only jsonl,自身不加锁。两个进程同时 --resume 同一个 session id,会交错往同一个文件里写,文件就坏了。
但 AgentHost 看不见用户在终端直接敲 codex --resume xxx 起的进程 —— 那不是 host 自己 spawn 的,没有进入内存池。怎么知道一个 session 我能不能放心 resume?
设计里用了两层判定:
第一层 · 进程标记 + pid 映射
AgentHost spawn agent 子进程时往 env 里塞 AGENTHOST_SESSION_ID=<sid> 和 AGENTHOST_OWNED=1,并在内存池里维护 session_id → pid 映射。判定时:
- 如果池里有这个 session,且
kill -0 pid通过 → 这是我现役的子进程,直接复用,根本不需要 resume。 - spawn 前用
lsof探到文件正被持有,但持有进程不带我的标记 → 是用户终端起的外部进程,不抢,fork。
第二层 · 文件基线对比(兜底)
但这只能抓”现在进行时”的占用。如果用户终端的 codex --resume 已经退出了,AgentHost 重启后看到一个属于自己的会话文件,怎么知道用户在我离开期间动过没?
办法是在干净边界上记基线 —— host 自己每次写完一个 turn、收到 turn/completed 时,把当时的 last_seen_size、last_seen_line_count、last_entry_id、last_seen_mtime 存进 DB。下次冷启动 resume 前,对比磁盘文件现状和 DB 基线:
- 一致 → 没人动过,安全 resume。
- 不一致 → 文件长出新内容了,外部 CLI 在我离开后 append 过 → fork。
关键是基线必须在干净边界采集。如果在 host 自己 streaming 一半时记基线,那段时间 host 自己的写入会被错认成”外部干扰”,每次都误 fork。
fork 作为统一出口
不管是哪种冲突场景:
- 外部会话(DB 无归属)
- 自己的会话被 CLI 干扰过(基线不一致)
- spawn 时探到文件正被外部进程占用
全部走 fork —— 底层映射成 codex 的 thread/fork、hx 的 --fork、claude 的自动 fork 行为。新 session 记 parent_session_id 追血缘,原来源文件不动。
这个设计的优点是 never 抢 jsonl,所以永远不会写坏别人的文件。哪怕用户在终端有 100 个并发 codex --resume,AgentHost 也只会乖乖 fork。代价是会话血缘树会比 git 还乱,但这是后续可视化的问题,不是数据正确性的问题。
设计点 5:FollowUpQueue 的 draining 标志
agent 的 prompt 是单工的 —— 一个 run 还在 streaming 时,再发一条 prompt 就被 adapter 拒掉(BUSY)。但用户体感上希望可以”连续追问” —— 我这条还没说完,再敲一条进去,不要被打回来”你急什么”。
设计里给每个 session 挂了一个 host-internal 的 FollowUpQueue,容量 3,超出就 sliding window 把队首挤掉。sendUserMessage 不再直接调 adapter.prompt,而是 enqueue + tick:
async sendUserMessage(sid, runId, item) {
queue.enqueue({ runId, ...item });
queue.tick(); // 幂等的 "poke"
}
tick() 的实现里有一个我觉得很巧妙的点 —— 用一个 draining 标志做并发安全:
async tick() {
if (this.draining || !this.idle()) return;
this.draining = true; // <- 同步设置,await 之前完成
try {
while (this.queue.length && this.idle()) {
const item = this.queue.shift();
await this.dispatch(item); // 这里 await
}
} finally {
this.draining = false;
}
}
JS 是单线程的,if (this.draining) return 这个判断和 this.draining = true 这个赋值之间不会被打断 —— 任何并发的 tick() 调用,要么先看到 draining=false 进入临界区,要么先看到 draining=true 直接返回。两个 sendUserMessage 撞一起,结果就是一个 enqueue + tick 真正派发,另一个 enqueue + tick 是 no-op,等 run.end 触发的 tick 接力。
这种”用一个布尔标志替代 mutex”的写法在 JS 里很自然但很容易写错 —— 关键是 draining = true 必须在第一个 await 之前完成,否则就漏出了竞争窗口。
队列里还有几个小细节:
- runId 在入队时就预分配:用户 enqueue 时立刻拿到
NewRoundResp { run_id },但这条 user_text 直到 dispatch 那一刻才发布到 Bus。这保证用户文本在 Bus 上的位置严格落在自己那一轮里,不会和上一轮的 assistant 事件交织。 - run.end 触发 tick 用 queueMicrotask 调度:onEvent 自身先把
run.end入 Bus,再让 dispatch 发布下一轮的 user_text,保证老 run.end → 新 user_text → 新 run.start顺序。 - wire 协议零变更:队列纯 host-internal,不上 Bus、不落 messages 表、不出现在
ListSessionRuns。线协议外部观察不到队列的存在,只看得到 run 一个接一个地跑。
设计点 6:Loopback WS Server 的 Grant Token
一期就同时支持远端 WS(host → 远端 bot)和本地 WS(本机 process → host)。本地这条是 boot 时自起的 loopback server,监听 127.0.0.1:<port>,端口写到 host.lock 里供本机 process 发现。
为什么不让 desktop 直接走 IPC 订阅消息?因为 IPC 是短连接、一次性 req/resp 的控制面,硬塞 streaming 消息会把它的简单性毁掉。WS 长连接更合适。
但问题是:如果 loopback server 裸开,本机任何进程都能拨进来订阅消息。哪怕是只暴露在 127.0.0.1,恶意进程也能扫端口。
设计里用了一套 Grant Token 机制:
Process X IPC (short-lived) agent-host
───────── ───────────────── ──────────
1. subscriber.add type:"local" ─────► GrantStore.mint(60s TTL)
◄────── {ws_endpoint, ws_token, token_ttl_s:60}
2. ws.connect ws://127.0.0.1:<port>/subscribe
Authorization: Bearer <ws_token> ─────► GrantStore.consume(token)
- 过期 → 401 reject
- 命中 → 升级 ws
3. 之后双向 frame 流(同一套 wire 协议)
关键约束:
- token 只在内存(
GrantStore),不写盘。host 重启后所有未消费 token 失效。 - TTL 60s,过期作废。但 TTL 窗口内可以多次 consume(断线重连不被打断)。
- IPC 只发券、不直接连 ws —— 把”申请权限”和”用权限”分开两个 channel,前者走 unix socket(0600,只能同 user 访问),后者走 loopback。
这设计还规划了进一步的安全分层(虽然 phase-1 没全落地):
| 层 | 控制 | 状态 |
|---|---|---|
| L0 | IPC socket 0600,仅同 user 可达 | 落地 |
| L1 | Local WS server 仅绑 127.0.0.1 | 落地 |
| L2 | WS upgrade 必须带 grant token(IPC 一次性发放、TTL 限制) | 落地 |
| L3 | grant 绑定 caller pid(unix socket SO_PEERCRED) | phase-2 |
| L4 | 配置层 principals 白名单:只允许特定 exe path / uid 申请券 | phase-2 |
| L5 | 单 process grant 申请速率限制 | phase-2 |
L0–L2 已经可以挡住”同机非同 user”和”同 user 但不知道 token”的攻击面。L3–L5 是给后续 hardening 留的钩子。
一个有意思的技术选型:CLI 而不是 SDK
最后一个值得说的设计是 —— 为什么选 CLI + stream-json 接 agent,而不是用每家的 SDK?
每家 SDK 都更好用:API 直观、性能更好、回调更细粒度。但有几个维度让我们最终选了 CLI:
| 维度 | CLI + stream-json | SDK in-process |
|---|---|---|
| 抽象一致性 | 三家 CLI 都把 stream-json 当官方协议,天然一致 | 每家 SDK 形态不同(claude 的 query() vs codex 的 Codex.run()),要写厚 adapter |
| 版本耦合 | 弱:CLI 是稳定外部协议 | 强:SDK minor 版本可能改 API |
| 进程隔离 | 强:crash / OOM / 死循环不拖垮 host | 弱:SDK 在你进程里跑,泄漏直接打到 host |
| 杀进程 / 超时 | 简单:SIGTERM 子进程 |
复杂:要走 SDK 的 interrupt(),未必干净 |
| 鉴权复用 | 直接用用户已登录的 CLI 凭据 | 要自己处理 API key / OAuth |
| 性能 | 多一次进程启动 + JSON 序列化(~50–200ms 冷启) | 没有进程开销 |
抽象一致性是关键 —— stream-json 已经是几家 agent 默认导出的”机器友好版本”,三家事件结构虽然不一样,但都是 NDJSON、都遵循 {event_type, payload} 大致同构的形态。这意味着 adapter 的工作就是字段映射,不需要做协议翻译。
进程隔离也很重要 —— agent 是个跑大模型的家伙,会 OOM、会卡死、会因为某个 tool 调用陷入死循环。把它放进子进程,host 至多 SIGTERM 一刀;放进同一进程,泄漏的内存就是泄漏的内存,下次 GC 也救不回来。
唯一妥协的是性能 —— 每次 spawn agent 子进程要 50–200ms。但这是会话级别的开销(spawn 一次跑很久),跟单 turn 的 LLM 调用比可以忽略。
Wrap
回头看,这个项目最有意思的不是哪个具体的技术点,而是它面对的约束组合:
- 三个 agent CLI,各自一套协议,要抹平成一份对外协议。
- 同一个 session 正在执行的事件流,要同时同步给远端 bot、本机 desktop、本机 TUI 三类 subscriber,且顺序与一致性不能破。
- 一个 host 进程要长时间常驻,但用户可能从终端、desktop、远端 bot 三个方向同时戳它。
- jsonl 文件是裸的、不加锁的,但用户可能在 host 之外直接操纵它。
- 网络可能断,subscriber 可能掉线,重连后要能续上。
- 本机其他进程要订阅消息流,但不能裸开 loopback 给所有进程。
每个约束单独看都不算难,组合起来才让设计有意思。最后的方案里我最满意的两个细节:
- bracket / delta 二分让”流式”和”持久化”自然分离,cursor 不会跳号。
- 进程标记 + 文件基线两层判定让外部 CLI 干扰能被识别出来,统一 fork 兜底。
如果你也在做类似的”协议聚合”项目(不一定是 agent CLI,比如多个 IM 协议、多个支付通道),希望这些设计点能给点启发。