跳到主要内容

数据截至 (上游 commit 3667151744e3)

控制面:让 agent 用 CLI 和 socket 指挥另一个 agent

30 秒导读: herdr 的服务端把整个会话(工作区/tab/pane/agent)暴露成一个 单行 JSON 的本地 socket 协议,外加一层 herdr CLI 外壳。于是「一个 agent 用 shell 命令去开、去喂、去等另一个 agent」变成了几行 bash。这一章讲的就是这个可编程面:方法有哪些、一次调用怎么落地、以及最关键的——怎么把「等 agent 干完」做成一个会返回的函数调用

本章是 herdr 系列的第 4 章。前置阅读建议:进程模型(谁在跑这个 socket)、agent 检测(idle/working/blocked 这些状态是怎么来的)。


1. 这是什么(零基础也能懂)

一句话定义: 控制面 = herdr 服务端对外开的一个本地 socket JSON 接口,加上把它包成人类/agent 可用命令的 herdr CLI。

它解决谁的什么问题。 假设你在终端里跑着一个 Claude Code,你想让它「顺手再开一个 Codex 去审我的 diff,等它审完再把结论拿回来」。没有控制面时,这件事做不了——子进程里再起一个 TUI agent,输出是 ANSI 乱码,你也无法知道它「是在思考还是在等你按 y」。

herdr 的答案是:别让 agent 去 fork 另一个 agent,让它去指挥服务端。服务端本来就管着一堆 PTY pane,也本来就在做 agent 状态检测,那就把这些能力开成接口。

它能做什么:

  • 建/关工作区、tab、pane,调布局(workspace.* / tab.* / pane.*)。
  • 在某个空闲 shell pane 里启动一个受支持的 agent,并等它真正可交互(agent.start)。
  • 给 agent 投喂 prompt、发按键、读它的屏幕(agent.prompt / agent.send_keys / agent.read)。
  • 阻塞等待:等某段输出出现、等 agent 回到空闲、等某个事件(pane.wait_for_output / agent.wait / events.wait)。
  • 扩展面:git worktree 开分支工作区、插件跑外部命令(worktree.* / plugin.*)。

用起来什么样。 这是 herdr 自带的 agent 契约文件里给出的典型序列(skills/herdr/SKILL.md:96-136),就是几条命令:

# 1. 在当前 pane 右边劈一个新 pane,不抢焦点
herdr pane split --current --direction right --cwd "$PWD" --no-focus
# 2. 在那个 pane 里起一个叫 reviewer 的 codex,返回时它已经能接收输入了
herdr agent start reviewer --kind codex --pane w1:p2
# 3. 投喂 prompt 并且【阻塞等到它干完】
herdr agent prompt reviewer "Review the current diff." --wait --timeout 120000
# 4. 把结果读回来
herdr agent read reviewer --source recent-unwrapped --lines 120

一句话直觉: 把它当成 终端会话版的 Docker CLIdocker run 之于容器,herdr agent start 之于 agent;区别是 herdr 还额外提供了 docker 没有的东西——"等这个进程从忙变闲" 这个原语。


2. 顶层全景(一次调用怎么走完)

怎么读这张图:从左到右是一次请求的完整生命周期;关键在最右边那个单线程事件循环——所有状态变更最终都串行地发生在那里。

调用方 API 服务端(每连接一线程) App 事件循环(单线程)
┌──────────────┐ 1行JSON ┌──────────────────────────┐ channel ┌────────────────────┐
│ herdr CLI │─────────▶│ ① 读首行请求(5s/1MiB上限)│─────────▶│ handle_api_request │
│ 或任意进程 │ socket │ ② 分流:普通 / 等待 / 流 │ │ → 改 AppState │
│ (直连socket) │◀─────────│ ③ 阻塞原语在这层自己轮询 │◀─────────│ → 返回 JSON 字符串 │
└──────────────┘ 1行JSON └──────────────────────────┘ channel └────────────────────┘
│ │
│ 读事件 │ 推事件
└────────▶ EventHub(512条环形)◀───┘

部件与职责:

部件干什么在哪个文件
Method 枚举92 个线上方法名与参数体的唯一真源src/api/schema.rs:45
start_server_with_capabilities绑 socket、0600 权限、每连接开一线程src/api/server.rs:67
dispatch_to_app把请求塞进 channel、同步等 App 回一个字符串src/api/server.rs:817
handle_api_request在 App 线程里把请求打到 AppStatesrc/app/api.rs:925
wait.rs 三个原语在服务端连接线程里做长轮询/事件驱动的等待src/api/wait.rs:22,132,177
EventHub有序号的环形事件缓冲,供订阅与等待复用src/api/event_hub.rs:2
herdr CLI参数解析 + 协议握手 + 退出码协议src/cli.rs:95

主线走一遍(高层): 客户端连上 socket → 写一行 JSON 请求 → 服务端解析成 Request → 若是普通方法就丢给 App 线程并同步等回应 → 写回一行 JSON → 关连接。一条连接只服务一个请求;流式方法(events.subscribepane.graphics.stream)和阻塞方法是这个规则的例外,它们把连接一直占着。


3. 方法表:控制面到底有多大

先看规模。 Method 枚举里有 92 个带 serde(rename) 的线上方法名,另有 4 个 serde(skip) 的内部变体(图形流的分步指令,不进线协议)。按命名空间分:

命名空间方法数干什么参数体所在
pane.*31pane 增删改查、输入、读屏、图形、上报src/api/schema/panes.rs
agent.*12agent 生命周期、投喂、等待、视图src/api/schema/agents.rs
plugin.*11插件登记、动作、日志、插件 panesrc/api/schema/plugins.rs
workspace.*9工作区src/api/schema/workspaces.rs
tab.*7tabsrc/api/schema/tabs.rs
server.*5停机、热交接、重载配置/规则表src/api/schema/server.rs
worktree.*4git worktree 列/建/开/删src/api/schema/worktrees.rs
layout.*3布局导出/应用/分割比src/api/schema/panes.rs
events.*2订阅流、等一个事件src/api/schema/events.rs
其余8ping / session.snapshot / notification.show / client.window_title.* / popup.close / integration.*各自文件

线格式。 请求是 {"id": ..., "method": ..., "params": ...}——Method#[serde(tag = "method", content = "params")] 做内部标签(src/api/schema.rs:41)。响应是 SuccessResponse { id, result }ErrorResponse { id, error: { code, message } }(src/api/schema/response.rs:24-39),result 自己再用 type 字段做标签。

一次真实往返长这样:

{"id":"cli:agent:get","method":"agent.get","params":{"target":"reviewer"}}
{"id":"cli:agent:get","result":{"type":"agent_info","agent":{"agent_status":"idle", ...}}}

schema 是生成的,不是手写的。 所有参数体都 derive(schemars::JsonSchema),protocol_schema_document() 把 5 个顶层入口(request / success_response / error_response / event / subscription_event)打成一个 250KB 的 bundle(src/api/schema/tests.rs:32-46)。这个 bundle 被 提交进仓库 且被测试盯着——generated_protocol_schema_artifact_is_current 会比对 docs/next/api/herdr-api.schema.json,不一致直接测试失败,要用 HERDR_UPDATE_API_SCHEMA=1 重新生成(src/api/schema/tests.rs:153-178)。

它还被 include_str! 编进二进制(src/cli/api.rs:1),所以 herdr api schema --json离线的:不需要连服务端就能把完整 schema 吐出来给一个 agent 读。摘要模式刻意做得很小(测试断言 < 400 字符,src/cli/api.rs:112-117),就是为了不炸 agent 的上下文窗口。


4. 请求通路:从 socket 字节到 AppState

这节讲一次调用在服务端内部的四道关卡。

4.1 关卡一:接连接

start_server_inner 绑好 socket、把权限收紧到 0600,然后为每一个进来的连接 spawn 一个 OS 线程(src/api/server.rs:82-127)。这是刻意的:阻塞原语要在连接线程里长期驻留,用线程比用 async 任务更简单。

首行读取有两道保护:5 秒读超时(INITIAL_REQUEST_TIMEOUT)和 1MiB 行长上限(MAX_INITIAL_REQUEST_BYTES),都在 src/api/server.rs:30-32

4.2 关卡二:分流

handle_connection_with_stop 按方法把请求分成四类走不同路径(src/api/server.rs:204-308):

类别方法连接行为
流式图形pane.graphics.stream长驻,持续写帧
订阅流events.subscribe长驻,每 100ms 轮询各订阅并推送
阻塞等待events.wait / agent.prompt / agent.wait / pane.wait_for_output长驻,直到匹配/超时/客户端断开
其余 88 个——单次请求-响应后关闭

注意 agent.prompt 在这里被截胡了:即使没带 wait,它也先进 prompt_agent,由后者判断要不要退化成一次普通 dispatch(src/api/wait.rs:185-193)。

4.3 关卡三:跨线程投递

dispatch_to_app 是唯一的过河点:构造一个 ApiRequestMessage(带一个 std::sync::mpsc 回信通道)发进 ApiRequestSender,然后同步等回信(src/api/server.rs:817-875)。

这里有个容易读漏的分寸:超时是可选的。

调用场景超时后果
普通外部请求(handle_requestdispatch_to_app(..., None, ...))App 线程卡住 = 客户端一起卡住
阻塞原语内部的探针(agent.get / pane.read)APP_RESPONSE_TIMEOUT = 5s探针超时会变成 server_unavailable,而不是把整个 wait 拖死

APP_RESPONSE_TIMEOUT 定义在 src/api/server.rs:29。轮询节拍 CONNECTION_POLL_INTERVAL = 100ms(src/api/server.rs:28),下面所有 wait 循环都用它。

4.4 关卡四:落到 AppState,以及「要不要重画」

App 线程收到消息后走 handle_api_request_message(src/app/runtime.rs:59),它先算一件事:

changed |= crate::api::request_changes_ui(&msg.request);

request_changes_ui 是一张硬编码的白名单,列了 56 个「会改变屏幕」的方法(src/api/mod.rs:22-82)。命中就置脏、触发重渲染;像 pane.readagent.getworkspace.list 这些只读方法不在表里,于是一个 agent 高频轮询读屏不会给渲染管线增加任何负担。这条线和 渲染管线 那章的「多路乘法性能路径」是同一件事的两端。

表里有个可直接读出的不对称:plugin.disable 在表内、plugin.enable 不在(src/api/mod.rs:76)。

真正的处理落在 handle_api_request 的一个大 match(src/app/api.rs:939 起),按域分派到 src/app/api/{panes,agents,tabs,workspaces,worktrees,plugins,layouts}.rs

4.5 例外:worktree 的延迟应答

worktree.createworktree.remove 要跑真正的 git worktree add/remove 子进程,不能在事件循环里同步等。所以它们被单独拎出来:

handle_api_request_message
└─ 是 WorktreeCreate/Remove?
├─ 是 → drain 内部事件 → handle_deferred_worktree_api_request(request, respond_to)
│ └─ 把 respond_to 【存起来】,git 跑完后由完成事件回填 ← 此时不回信
└─ 否 → handle_api_request(...) → 立即 respond_to.send(response)

依据:src/app/runtime.rs:76-89src/app/api/worktrees/deferred.rs:15-31。客户端侧毫无感知——它只是在 dispatch_to_app 的无超时 recv() 上多等了一会儿。


5. 阻塞式原语(本章最关键的一节)

5.1 要解决的小问题

CLI 世界里,「命令返回了」等于「事情做完了」。但 agent 不是这样:agent.prompt 只是往 PTY 里写了几个字节,写完就返回了——此时 agent 一个 token 都还没吐。

于是控制面必须提供一个东西:一个会在"事情做完"时才返回的调用。herdr 给了三个,语义各不相同。

5.2 三者对比

原语等什么驱动方式谁调用
pane.wait_for_output屏幕上出现某个子串/正则纯轮询 pane.read任何 pane(跑测试、跑 server)
agent.waitagent 状态进入某个集合事件驱动 + 探针复核已经在跑的 agent
agent.prompt --wait投喂之后 agent 走完一轮两段式:先证明有反应,再等落定投喂 + 等待一步完成
events.wait一个事件匹配复用订阅机制只支持一种匹配(见下)

5.3 pane.wait_for_output:最朴素的那个

思路直白:循环 → 读屏 → 匹配 → 命中就返回,否则睡 100ms。核心就这几步(src/api/wait.rs:22-129):

let matched_line = match_output(&read.text, &params.r#match, regex.as_ref());
if matched_line.is_some() { /* 返回 output_matched */ }
if deadline.is_some_and(|d| std::time::Instant::now() >= d) { /* 返回 timeout */ }
std::thread::sleep(CONNECTION_POLL_INTERVAL);

三个不显然的细节:

  • 正则先编译再进循环。编译失败直接返回 invalid_regex,不会在循环里反复失败(src/api/wait.rs:34-51)。
  • 匹配是逐行的,match_outputtext.lines() 找第一条命中行(src/api/subscriptions.rs:20-36)。所以跨行的正则匹配不到。
  • 读取口径会被悄悄改写:output_match_read_sourceRecent 换成 RecentUnwrapped(src/api/subscriptions.rs:11-18)。原因很实际——终端软换行会把 test result: ok 从中间劈开,不解包就匹配不上。
  • 它不区分"新旧"。第一次读到的快照里如果已经有目标文本,立刻就命中。SKILL.md 明写了这一点(skills/herdr/SKILL.md:172)。

5.4 agent.wait:事件驱动 + 探针复核

思路: 光轮询 agent.get 太浪费,光信事件又可能漏。herdr 的做法是事件只当"该去看一眼"的触发器,真值永远来自一次探针

EventHub.events_after(seq)

┌──────────┴───────────┐
│ 与本 pane 相关的事件? │
└──────────┬───────────┘
│ 是
┌───────┴────────┐
│ 生命周期类事件? │──是──▶ PaneClosed/PaneExited/PaneMoved → agent_not_running(直接失败)
└───────┬────────┘
│ 否(状态变化/pane 更新)
should_probe = true

agent.get 探针 ──▶ 身份还对得上吗? ──否──▶ agent_not_running
│ 对
状态 ∈ until ? ──是──▶ 返回 AgentInfo

依据:wait_for_resolved_agent(src/api/wait.rs:348-498),身份校验 agent_wait_identity_matches(src/api/wait.rs:525-538)。

几个精华点:

  • 进门先查一次wait_for_agent 在进循环前就做一次 agent_get,若当前状态已满足直接返回(src/api/wait.rs:141-152)。这意味着 agent.wait 匹配的是状态,不是转换
  • 默认 until 是三个"落定态":idle / done / blocked(agent_wait_statuses,src/api/wait.rs:511-523)。workingunknown 要显式指定——因为它们不代表"该回来找我了"。
  • 身份漂移视为失败。pane 被移走、agent 换了 kind、terminal_id 变了,一律返回 agent_not_running 而不是傻等(src/api/wait.rs:400-436)。
  • 只有 agent_not_found 会被翻译成 agent_not_running,其它探针错误(比如 server_unavailable)原样透传——有专门的测试盯着(src/api/wait.rs:787-811)。
  • 客户端断开即取消。每轮循环开头 should_stop_connection 会检查 peer 是否关了 socket(src/api/server.rs:775-784);断开就静默收工,不写响应。Ctrl-C 一个 herdr agent wait 就能干净地取消服务端的等待,不需要任何 cancel 方法。

5.5 agent.prompt --wait:两段式与 stall 保护

这是整个控制面里最精细的一段逻辑,也是最值得学的。

它要解决的坑: 你投喂了 prompt,然后等 idle。但 agent 本来就是 idle——如果它根本没收到你的输入(TUI 吞了、焦点丢了、粘贴模式不对),你会立刻拿到一个"成功"的 idle,然后开开心心去读一个空结果。

herdr 的解法:先要求看到"有反应"的证据,再去等"落定"

prompt 提交

├─ 提交前状态 == Working? ──是──▶ 跳过第一段(它本来就在动)

└─ 否 ── 第一段:证明有反应 ───────────────────────────────┐
until = 【全部 5 个状态】 │
条件 = state_change_seq > 提交时的基线 │
超时 = min(用户 timeout, 5000ms) │
│ │
├─ 没等到 ──▶ 用户 timeout > 5s → agent_prompt_stalled
│ 用户 timeout ≤ 5s → timeout
└─ 等到 ────▶ 第二段:等落定(until = idle/done/blocked)
超时 = 用户 timeout 的剩余部分

关键源码锚点(src/api/wait.rs:226-305):

let effect_timeout_ms = wait.timeout_ms
.map_or(AGENT_PROMPT_EFFECT_TIMEOUT_MS, |t| t.min(AGENT_PROMPT_EFFECT_TIMEOUT_MS));

AGENT_PROMPT_EFFECT_TIMEOUT_MS = 5_000(src/api/wait.rs:20)。「有反应」的判据是 agent_wait_matches 里那个 after_state_change_seq 门槛(src/api/wait.rs:540-547)——必须看到状态序号严格递增,光"状态值相同"不算数。

第一段用的 untilall_agent_statuses(),注释写得很清楚:每一个状态都是"序号前进了"的证据(src/api/wait.rs:500-509)。

错误信息也做得很实在,把诊断信息直接塞进 message(src/api/wait.rs:626-629):

agent prompt produced no observed state change within 5000 ms;
status is idle and state_change_seq remained 41

还有一条诚实的边界,herdr 自己在 CLI 帮助文本里写明了:它跟踪的是生命周期状态,不是一个 turn;如果 agent 本来就在 working,那个正在进行的 turn 结束也会算数(src/cli/spec.rs:367)。这是检测机制的固有限制,不是 bug。

5.6 events.waitEventHub

EventHub 是个极简结构:一个 Mutex<Vec<(u64, EventEnvelope)>>,自增序号,上限 512 条,溢出从头 drain(src/api/event_hub.rs:13-26)。等待方靠 events_after(seq) 拉增量。

512 条是有含义的:一个等待方如果 100ms 内没被调度,而这期间涌进了 512 条以上事件,它就会漏掉最早的那些。这也是为什么 agent.wait 不敢只信事件——必须配探针复核。

events.wait 复用了订阅机制:把 EventMatch 翻成一个 Subscription,包成 ActiveSubscription,然后循环 poll_for_wait(src/api/wait.rs:661-715)。

但它现在只支持一种匹配。 EventMatch 枚举里定义了 19 种(src/api/schema/events.rs:116-190,WorkspaceCreatedPaneAgentStatusChanged),而 event_match_subscription 只认 PaneAgentStatusChanged,其余一律返回 unsupported_event_wait_match(src/api/wait.rs:717-737)。schema 比实现宽——用之前要看这一点。

5.7 订阅流:去重与"快照 vs 事件"的赛跑

events.subscribe 是长连接:握手先回一条 subscription_started,之后每 100ms 轮询所有订阅(src/api/server.rs:686-742)。有两处值得学:

  • 输出匹配订阅有边沿去重:currently_matching 标志保证同一段持续存在的文本只推一次事件(src/api/subscriptions.rs:357-376)。
  • 状态订阅会处理"快照和事件流赛跑":在拿 pane 快照前后各读一次 event_hub.current_sequence(),若期间来了新事件就丢弃这次快照、下轮重来(src/api/subscriptions.rs:454-470)。事件流永远优先于轮询快照。

6. agent 生命周期动作

6.1 agent.start:三道门 + 一个服务端/客户端分工

服务端侧的 start_agent 是纯校验 + 发字节,它不等(src/app/agents.rs:145-227)。它检查:

检查失败错误码依据
名字合法invalid_agent_namevalid_agent_name,src/app/agents.rs:15-20
kind 受支持unsupported_agent_kindsrc/app/agents.rs:153
参数无控制字符invalid_agent_argumentsrc/app/agents.rs:156-162
名字未被占用agent_name_takenagent_name_conflicts,src/app/agents.rs:399
目标 pane 是空闲 shellagent_pane_busysrc/app/agents.rs:185-193
超时在窗口内invalid_agent_timeoutsrc/app/agents.rs:205-207

三个时间常量(src/app/agents.rs:8-10):

常量含义
DEFAULT_AGENT_START_TIMEOUT30s未指定 --timeout 时的启动等待上限
MAX_AGENT_START_TIMEOUT300s允许的最大值
AGENT_START_SETTLE_DELAY3s允许的下界——超时必须 >

超时的合法区间是 (3000ms, 300000ms],而不是 [0, 300000]。理由很实在:agent 启动头几秒屏幕上什么都还没有,3 秒以内的超时必然误判。

名字语法故意收得很窄:[a-z][a-z0-9_-]{0,31}。测试直接把设计意图写在名字里——agent_names_use_a_small_cli_safe_grammar(src/app/agents.rs:474)。这是为了让名字能安全地当 CLI 位置参数,不需要引号。

"等它真的能用"这一步在客户端做。 wait_for_named_agent 在 CLI 里每 100ms 轮询 agent.get,并做一张真值表(src/cli/agent.rs:548-618):

观察到判定
terminal_id 变了 / name 丢了agent_name_lost(失败)
检测到的 kind ≠ 请求的 kindagent_kind_mismatch(失败)
blockedagent_not_ready(失败,启动期就卡住了)
working / unknown继续等
idle/doneinteractive_ready成功
idle/done不是 launch_pendingagent_start_failed(进程起来又死了)

最后一行是全表最妙的:没在启动中、又已经空闲、却从没进过可交互态 = 进程启动后立刻退出了。这个组合把"命令不存在""立刻崩了"从"还没起来"里区分了出来。

启动路径上还有一层重试:碰到 agent_pane_busy 时,如果那个 pane 看起来只是 shell 还在初始化(读 pane.process_info 判断),就在 2 秒窗口内重试(src/cli/agent.rs:341-390)。

6.2 agent.prompt:agent_blocked前置拒绝

这是一条重要的语义:

if terminal.state == crate::detect::AgentState::Blocked {
return encode_error(id, "agent_blocked", format!("agent {} is blocked and requires interactive input", params.target));
}

src/app/api/agents.rs:82-91注意它在任何字节发出去之前。含义是:如果 agent 正停在一个审批/提问对话框上,herdr 不会把你的 prompt 当成对话框的答案打进去。这避免了一个很危险的失误——把 "Review the diff" 敲进一个 "Allow write to /etc? (y/n)" 的提示符。SKILL.md 把处置方式也写死了:先看那个对话框,再问人(skills/herdr/SKILL.md:128)。

提交本身分两拍:先写文本,300ms 后再写回车(AGENT_PROMPT_SUBMIT_DELAY,src/app/api/agents.rs:13,126)。GitHub Copilot 还要额外先补一个 focus-gained 序列,因为它在失焦后会忽略合成的 Enter(src/app/api/agents.rs:111-120)。


7. 读取口径:ReadSourceReadIntent

7.1 四种读法

ReadSource 有四个取值(src/api/schema/common.rs:62),不是同一份数据的四种格式,而是四个不同的取样面:

取值取的是什么什么时候用
visible当前渲染的视口想看"用户此刻看到什么"
recent近期输出,保留软换行想还原屏幕排版
recent_unwrapped近期输出,软换行接回一行日志和长文本首选
detectionagent 检测用的纯文本底部缓冲快照调检测规则时用

detection 这一档专门存在,是因为用户可以滚动视口——拿视口做状态判断会被用户的滚轮弄错。这条约束和 agent 检测 那章是一体的。

7.2 ReadIntent 为什么必须区分

PaneReadParams 里有个字段带着 #[serde(skip)] + #[schemars(skip)](src/api/schema/panes.rs:284-286):

pub(crate) intent: super::common::ReadIntent,

不上线协议、不进 schema——纯粹是服务端内部标记谁发起的这次读。取值只有 Interactive(默认)和 Passive(src/api/schema/common.rs:69-74)。

区别在于:一次"读"其实可能有副作用。 当 agent 跑在 alt screen(备用屏)上时,历史行不会进 herdr 的 host scrollback,普通读法拿不到。herdr 的对策是真的去滚那个 pane:注入向上滚轮事件、逐屏收割、再滚回原位(PendingAltScreenRead,src/server/alt_screen_read.rs:27,总时限 15 秒)。

它的状态机有五个阶段,按枚举声明序SettleInitial / ProbeBottom / RestoreProbe / Harvest / Restore(src/server/alt_screen_read.rs:19-25)。注意这不是执行顺序:RestoreProbe 是一条岔路而不是第三站——滚轮事件发出去画面纹丝不动时才走它,然后直接降级返回。完整流程图见 终端内核 §5.3

这个动作只对 Interactive 开放:

Method::PaneRead(params) if params.intent == ReadIntent::Interactive => ( ... )

src/server/headless.rs:3410

于是分工很清楚:

谁在读intent能否触发滚屏收割
herdr pane read(人/agent 显式调用)Interactive(src/cli/pane.rs:539)
wait_for_output 的轮询探针Passive(src/api/wait.rs:67)不能
订阅的 pane_read 探针Passive(src/api/subscriptions.rs:560)不能

没有这个区分会怎样: 一个 pane.wait_for_output 每 100ms 读一次,每次都可能触发一轮"滚上去再滚回来"——用户的屏幕会疯狂抖动,而且滚屏本身要 10 多秒,轮询会彻底堵死。Passive 就是"我只是来偷看一眼,别为我动 pane"。

顺带澄清一个常见混淆:决定 idle 还是 done 的那个 seen 标记,和 ReadIntent 无关。seen 由聚焦动作打(focus_agent_target 里的 mark_active_tab_seen,src/app/agents.rs:82),而所有 read 处理器都不碰它——所以 SKILL.md 那句「CLI reads do not mark it seen」(skills/herdr/SKILL.md:58)成立的原因是"读根本不打 seen",不是"passive 读不打"。


8. CLI 外壳:错误协议才是重点

8.1 结构

herdr 的 CLI 是手写的分发 + clap 只用来出 help,这是个不常见但有理由的选择:

  • maybe_run 是一个字符串 match,把 agent/pane/workspace/... 分给 src/cli/*.rs(src/cli.rs:95-130)。返回 CommandOutcome::NotCli 时才让主程序去起 TUI。
  • src/cli/spec.rs:5command() 建了一棵完整的 clap 命令树,但只用来渲染 --help(print_requested_help,src/cli/spec.rs:65-110)。真正的参数解析是各子模块里的手写 while 循环。

这带来一个实际好处:help 文本可以写得很长很教学化,而不影响解析逻辑。agent promptafter_help 就是一整段关于 stall 语义的说明(src/cli/spec.rs:367)。help 还带了给 AI 的路由脚注 AGENT_HELP_FOOTER(src/cli.rs:46-54)。

8.2 退出码协议

SKILL.md 把契约写成了一句话(skills/herdr/SKILL.md:195):服务端错误是 stderr 上的 JSON,退出 1;语法错误退出 2。落到代码:

情况stdoutstderr退出码依据
成功一行 JSON 响应——0print_response,src/cli.rs:738-746
服务端返回 error——一行 JSON 错误1同上,src/cli.rs:739-742
参数/用法错——人类可读 usage2src/cli.rs:719
pane read 成功裸文本(非 JSON)——0print_read_response,src/cli.rs:84-93

对 agent 来说这套很好用:退出码分流,stdout 恒为可解析结果,stderr 恒为 JSON 错误read 是唯一刻意的例外——直接吐文本,省掉一次 JSON 解码。

8.3 两道"连不上/版本不对"的守卫

协议版本守卫。 每次 send_request 都会先发一个 ping 比对协议版本(ensure_server_protocol_compatible,src/cli.rs:777-797)。也就是说一条普通 CLI 命令是两次往返。不匹配时的错误信息会分方向给出可执行建议(src/cli/protocol_guard.rs:26-34):

  • 客户端更新 → "重启服务端",并附上会话相关的重启命令。
  • 服务端更新 → "升级客户端"。

轮询热路径用 send_request_unchecked 跳过这次握手(如 resolve_agent_target_unchecked,src/cli/agent.rs:721-726)。

服务端不在守卫。 连不上时不是抛 raw io error,而是造一个 server_not_runningErrorResponse,消息里直接给出"跑这条命令去启动/接管"(src/cli/server_not_running.rs:28-40)。

这里有个精巧的分寸:这个错误被包成一个 marker error 但先不打印。原因写在注释里——有些命令(比如插件的离线兜底)本来就能在无服务端时继续工作,由最终暴露错误的那一层决定打不打(src/cli/server_not_running.rs:56-74)。分类只看 ErrorKind::NotFound | ConnectionRefused,不看 errno,因为 Windows 命名管道的 raw errno 和 Unix domain socket 不一样(src/cli.rs:816-824)。


9. 面向 agent 的契约:SKILL.md 与注入的身份

控制面的最后一块拼图不是代码,是文本:herdr 自带一份 skills/herdr/SKILL.md,专门写给要驱动 herdr 的 agent 看,herdr --skill 可以把它打印出来(src/cli/spec.rs:21)。

它有几条设计上值得注意的取向:

  • 把"二进制是权威"写进契约:让 agent 先跑 herdr --help 和各命令组,而不是背命令语法(skills/herdr/SKILL.md:20-42)。这样文档过期也不会让 agent 编造参数。
  • 明确劝退:"不要跑裸 herdr,那会拉起 TUI";"不要靠省略参数去探测会改状态的子命令"(skills/herdr/SKILL.md:42)。
  • 明确 ID 是不透明句柄,只能从 JSON 响应里取,不能从侧边栏顺序或例子里推(skills/herdr/SKILL.md:62-68,191)。

身份注入。 每个 herdr 管理的 pane 在 spawn 时会被塞进三个环境变量(src/pane.rs:129-151):

变量内容常量定义
HERDR_WORKSPACE_IDw1src/integration/env.rs:10
HERDR_TAB_IDw1:t1src/integration/env.rs:9
HERDR_PANE_IDw1:p1src/integration/env.rs:8

外加 HERDR_ENV=1(是否在 herdr 里)和 HERDR_SOCKET_PATH(socket 在哪,src/integration/env.rs:28-33)。

这三个变量解决的是"我是谁"的问题。 没有它们,一个跑在 pane 里的 agent 只能用"当前聚焦的 pane",而那个 pane 可能属于用户或另一个客户端。有了它们,CLI 的 --current 才能成立——它就是把 $HERDR_PANE_ID 填进 caller_pane_id 字段(src/cli/pane.rs:101-140,参数体在 src/api/schema/panes.rs:241)。

注意 PaneLaunchIdentity 有第三种取值 OmitPane,会主动删掉 HERDR_PANE_ID(src/pane.rs:148-150)——用于那些不该继承调用方身份的启动路径。


10. 扩展面:插件与 worktree

控制面除了"操作已有对象",还开了两扇让外部东西进来的门。

10.1 插件:让外部命令拿到会话上下文

11 个 plugin.* 方法覆盖三件事:登记(link/unlink/enable/disable/list)、跑动作(action.list/action.invoke/log.list)、开插件 pane(pane.open/focus/close)。

执行模型: start_plugin_command fork 一个子进程,把会话上下文用环境变量喂进去(src/app/api/plugins/runtime.rs:16-81):

HERDR_ENV=1 · HERDR_SOCKET_PATH · HERDR_BIN_PATH
HERDR_PLUGIN_ID / _ACTION_ID / _EVENT / _EVENT_JSON / _CONTEXT_JSON
HERDR_WORKSPACE_ID / HERDR_TAB_ID / HERDR_PANE_ID ← 来自 invocation context

于是插件进程本身也是控制面的一等客户端:它拿到了 socket 路径和 herdr 二进制路径,可以反过来调 API。

三条护栏:

护栏依据
并发命令上限32,超了返回 plugin_command_limit_reachedsrc/app/api/plugins/runtime.rs:12,82-102
输出截断64KiBsrc/app/api/plugins/runtime.rs:11
日志环形200 条src/app/api/plugins/runtime.rs:13

插件 pane 还有个 env 保护名单:插件 manifest 里声明的 env 会先被过滤,任何试图覆写 HERDR_ENV / HERDR_SOCKET_PATH / HERDR_PLUGIN_* / HERDR_BIN_PATH 的键都被丢弃(plugin_pane_protected_env_key,src/app/api/plugins/panes.rs:346-359)。插件不能伪造自己的身份。

路径与登记: 托管插件落在 <config>/plugins/github/<hash>,配置在 <config>/plugins/config/<hash>,状态在 <state>/plugins/<hash>(src/plugin_paths.rs:5-25)。登记表是一个带文件锁的 JSON(src/persist/plugin_registry.rs:19,59),每次读都会 reload_manifests 重新解析 manifest,manifest 读不到时保留条目但挂一条警告而不是删掉(src/persist/plugin_registry.rs:112)。跨平台的 program 解析(Windows 的 .bat 要走 cmd /d /c)集中在 src/plugin_command.rs:7-50

10.2 git worktree:给"多 agent 并行"配的隔离

worktree.* 四个方法把 git worktree 包成了工作区级别的概念:

方法干什么落地
worktree.list列出仓库的所有 worktree,标注哪些已经开着工作区src/app/api/worktrees.rs:48
worktree.creategit worktree add + 开一个新工作区(延迟应答)src/app/api/worktrees/deferred.rs:15
worktree.open打开一个已存在的 worktree 为工作区src/app/api/worktrees.rs:75
worktree.remove移除(延迟应答)src/app/api/worktrees/deferred.rs:25

git 侧的构造与解析在 src/worktree.rs:build_worktree_add_new_branch_command:228parse_worktree_list_porcelain:404(解析 git worktree list --porcelain)、list_existing_worktrees:473。删除路径有专门的错误分类和恢复——脏工作区、"不是 working tree"各有识别函数(src/worktree.rs:180-198),以及一条带恢复的删除(run_worktree_remove_command_with_recovery:328)。

为什么这属于控制面: 让 N 个 agent 并行改同一个仓库,最干净的隔离就是 N 个 worktree。把它开成 API,意味着一个 agent 可以给自己开一个隔离的分支工作区再动手,不用人类先手动 git worktree add


11. 巧妙之处(可带走的东西)

  1. 把「等待」放在协议层而不是客户端。 大多数系统让客户端自己轮询;herdr 把 wait 做成方法,客户端只需要一次阻塞调用。副产品是取消变免费了——关 socket 就是取消(src/api/server.rs:775)。

  2. 事件当触发器,探针当真值。 事件缓冲只有 512 条、可能溢出、可能乱序观察;所以 agent.wait 从不直接信事件里的状态,只把它当"去查一下"的信号,真值来自 agent.get(src/api/wait.rs:441-467)。这套组合既省了 100ms 的固定轮询延迟,又不会因为丢事件而卡死。

  3. state_change_seq 做"有反应"的证据。 判断"输入生效了没"最难的地方在于:状态值可能前后相同(idle → working → idle 太快)。用单调递增的序号当门槛(src/api/wait.rs:546),就把"没变化"和"变了又变回来"分开了。这是任何"提交后确认生效"场景都能借的技巧。

  4. request_changes_ui 白名单。 一张 56 项的显式表,把"读"和"写"在渲染代价这个维度上彻底分开(src/api/mod.rs:22)。于是 agent 可以放心地高频读屏。

  5. ReadIntent 这个不上线的字段。 同一个方法,内部调用和外部调用需要不同的副作用许可。做法不是加一个新方法,而是加一个 #[serde(skip)] 的内部字段(src/api/schema/panes.rs:284)——协议面不变,内部语义分开。

  6. schema 是被测试盯住的构建产物。 生成 + 提交 + 断言一致 + include_str! 进二进制(src/api/schema/tests.rs:153,src/cli/api.rs:1),一次性解决了"文档过期"和"要连服务端才能拿 schema"两个问题。

  7. 错误消息里带可执行的下一步。 server_not_running 附启动命令、protocol_mismatch 分方向给建议、agent_prompt_stalled 附上当时的状态和序号。对一个只能读文本的 agent 来说,这比错误码有用得多。


12. 边界与局限(诚实版)

  • events.wait 名不副实。 schema 里 19 种 EventMatch,实现只支持 pane.agent_status_changed,其余返回 unsupported_event_wait_match(src/api/wait.rs:729-736)。

  • events.subscribe / events.wait 没有 CLI。 src/cli/spec.rs 里搜不到 events 相关子命令。事件面目前是纯 socket 能力,shell 里的 agent 用不到,只能靠 agent.wait / pane.wait_for_output

  • 普通请求没有超时。 外部请求走 dispatch_to_app(..., None, ...)(src/api/server.rs:379),App 线程若被长任务占住,客户端就一起挂着,只能自己断连。

  • 每个阻塞调用占一个 OS 线程。 每连接一线程(src/api/server.rs:107),同时挂 50 个 agent.wait 就是 50 个线程。规模上的代价代码里没有做缓解。

  • 轮询节拍固定 100ms。 CONNECTION_POLL_INTERVAL 是常量,没有退避也不可配(src/api/server.rs:28)。pane.wait_for_output 因此是恒定 10Hz 的读屏。

  • 输出匹配是逐行的。 match_outputlines() 逐行试(src/api/subscriptions.rs:26-34),跨行正则无效。

  • --wait 不认 turn,只认状态。 已经在 working 的 agent,上一轮结束就会满足等待——herdr 自己在帮助文本里承认了这点(src/cli/spec.rs:367)。

  • alt screen 的历史拿不回来。 一旦滚出 alt screen,行不会进 host scrollback,--lines 加多少都没用;SKILL.md 给的兜底是让 agent 把结果写成文件再去读(skills/herdr/SKILL.md:183-185)。

  • 每条 CLI 命令两次往返。 协议握手 ping 是无条件的(src/cli.rs:764),在密集脚本里是可观察的额外开销。


13. 横向对比

同货架上"驱动别的程序"的项目,取舍差别主要在边界画在哪:

取向herdr常见替代做法
控制粒度会话对象(工作区/pane/agent)进程(spawn + 管道)
"干完了"怎么知道服务端的屏幕状态检测 + 阻塞原语进程退出码 / 输出哨兵串
目标程序要不要改不用改,黑盒驱动 TUI通常要求目标是非交互 CLI
接口形态本地 socket JSON + CLI 双面单一 SDK 或单一 CLI

herdr 的独特点在于它不要求被驱动方配合:被指挥的 Claude Code / Codex 完全不知道自己在被 herdr 指挥,状态判断靠外部观察屏幕(见 agent 检测)。代价就是第 12 节那些"只认状态不认 turn"的模糊性。


14. 代码地图(导航索引)

主题文件路径关键符号
方法枚举(线协议真源)src/api/schema.rsMethodRequest
读取口径src/api/schema/common.rsReadSourceReadIntentAgentStatus
agent 参数与响应体src/api/schema/agents.rsAgentStartParamsAgentPromptParamsAgentWaitParamsAgentInfo
事件与匹配src/api/schema/events.rsSubscriptionEventMatchPaneWaitForOutputParamsOutputMatch
响应封装src/api/schema/response.rsSuccessResponseErrorResponseResponseResult
schema 生成与固化src/api/schema/tests.rsprotocol_schema_documentgenerated_protocol_schema_artifact_is_current
socket 服务端src/api/server.rsstart_server_with_capabilitieshandle_connection_with_stopdispatch_to_appAPP_RESPONSE_TIMEOUTshould_stop_connectionstream_subscriptions
阻塞原语src/api/wait.rswait_for_outputwait_for_agentprompt_agentwait_for_resolved_agentAGENT_PROMPT_EFFECT_TIMEOUT_MSagent_wait_matches
订阅src/api/subscriptions.rsActiveSubscriptionmatch_outputoutput_match_read_source
事件缓冲src/api/event_hub.rsEventHubevents_aftercurrent_sequence
重渲染白名单src/api/mod.rsrequest_changes_uiApiRequestMessageSOCKET_PATH_ENV_VAR
客户端库src/api/client.rsApiClientrequest_valueConnectionTarget
App 侧分派src/app/api.rshandle_api_requesthandle_api_request_after_internal_events_drained
App 侧消息入口src/app/runtime.rshandle_api_request_message
agent 生命周期src/app/agents.rsstart_agentvalid_agent_nameDEFAULT_AGENT_START_TIMEOUTMAX_AGENT_START_TIMEOUTAGENT_START_SETTLE_DELAY
agent 请求实现src/app/api/agents.rshandle_agent_prompthandle_agent_readAGENT_PROMPT_SUBMIT_DELAY
alt screen 收割src/server/alt_screen_read.rs / src/server/headless.rsPendingAltScreenReadPhasealt_screen_read_spec
CLI 分发src/cli.rsmaybe_runsend_requestprint_responseensure_server_protocol_compatible
CLI 命令树 / helpsrc/cli/spec.rscommandprint_requested_helpagent_command
CLI agent 子命令src/cli/agent.rswait_for_named_agentagent_waitresolve_agent_target_unchecked
CLI 错误协议src/cli/protocol_guard.rs / src/cli/server_not_running.rsmismatch_responseServerNotRunningReportedreported_response
schema 自省命令src/cli/api.rsapi_schemaAPI_SCHEMA_JSON
agent 契约文本skills/herdr/SKILL.md——
身份注入src/integration/env.rs / src/pane.rsHERDR_PANE_ID_ENV_VARapply_pane_base_envapply_pane_launch_env
插件 APIsrc/app/api/plugins/mod.rshandle_plugin_action_invokehandle_plugin_pane_open
插件执行src/app/api/plugins/runtime.rs / src/app/api/plugins/panes.rsstart_plugin_commandMAX_PLUGIN_COMMANDS_IN_FLIGHTplugin_pane_protected_env_key
插件路径与登记src/plugin_paths.rs / src/persist/plugin_registry.rs / src/plugin_command.rsmanaged_checkout_pathreload_manifestscommand_for_argv_in_dir
worktreesrc/worktree.rs / src/app/api/worktrees.rs / src/app/api/worktrees/deferred.rsparse_worktree_list_porcelainhandle_worktree_openhandle_deferred_worktree_api_request