从一个现象开始
Herdr 是一个给 coding agent 用的终端 runtime——README 里那句 slogan 很准确:“the runtime your coding agents live on”。它自己不包装、不替代任何 agent,而是拥有它们的终端:Claude Code、Codex、Cursor CLI、OpenCode 等都原样跑在 Herdr 的 pane 里,由一个后台 server 统一托管。
于是一个有意思的现象出现了:主 pane 里的 agent(比如一个 Claude Code 实例)把任务派发给其他 pane 的 agent 之后,它能”实时”看到对方窗口的输出,等对方干完了再收割结论。多个 agent 就这样在一个终端里协同工作。
直觉答案会是”Herdr 把输出转发给了主 pane”。这个答案是错的。本文拆开这个机制,并顺着往下挖一层:这种协同方式和 Claude Code 原生的 subagent 派发,在技术实现上到底差在哪,各自的上下文管理代价是什么。
以下代码事实基于 2026-09 的 herdr master 分支(commit 3f2a6e7),文中的文件与函数名都可以在 github.com/herdrdev/herdr 对上。
一、核心机制:状态外置的终端仿真
“实时可见”靠的不是转发,而是四个环环相扣的架构事实。
1. 所有 PTY 都由 server 持有
每个 pane 里的进程,其 stdin/stdout 接在 server 创建的 PTY 上,而不是 TUI 客户端,更不是主 pane。输出第一站永远是 server。这就是为什么你关掉客户端、断开 SSH,pane 里的 agent 还在继续跑——README 明确把这个作为首要特性:“herdr keeps terminals running in a background server when you close the client”。
2. server 对每个 pane 逐字节做终端仿真
PTY 读循环的回调在 src/pane.rs 里,每次读到的字节都交给 terminal.process_pty_bytes(...),由 vendored 的 libghostty-vt 终端核心在 server 内存里维护每个 pane 的完整终端状态:屏幕缓冲、scrollback、主/备屏。
关键在于隐藏 pane 也在持续解析。这是 Herdr 项目规则里明确写的:“Hidden panes still parse output”。不管 pane 是否可见、是否被聚焦,server 任何时刻都持有它最新的”屏幕内容”,这份状态不依赖有没有人在看。
3. 内容进入某个 agent 的 context 只有一条路:显式拉取
主 pane 里的 agent 执行 herdr agent read <pane> 或 herdr pane read <pane> --source recent-unwrapped,CLI 通过会话 socket 发 JSON API 请求,server 端的 handle_pane_read(src/app/api/panes.rs)直接从目标 pane 的终端状态取快照(read_terminal_snapshot,src/app/api_helpers.rs):
# 主 pane 里的 agent 这样收割子 agent 的结论
herdr agent get reviewer # 小的结构化状态
herdr agent read reviewer --source recent-unwrapped --lines 120 # 一次有界读取
四个读源各有用途:visible 是当前视口,recent/recent-unwrapped 是回滚区尾部(后者把软折行拼合,适合日志和转录),detection 是检测引擎用的 bottom buffer 快照。读取是有界的:--lines 对 recent 源默认 80 行、硬上限 1000 行。
4. “实时”来自服务端阻塞等待 + 事件信号
同步不需要 agent 自己轮询:
herdr agent wait/agent prompt --wait:阻塞发生在 server 侧,等内部 EventHub(一个 512 条容量的带序号环形缓冲)推送 agent 状态事件,状态一变立刻返回一个很小的状态 JSON。herdr pane wait-output --match/--regex:server 侧以 100ms 间隔重读快照做匹配,命中即返回,而且只返回匹配到的那一行。
值得强调的是事件系统的克制:我核对过 API schema 里全部事件类型,pane.output_changed 只携带 pane ID 和 revision 号,pane.agent_status_changed 只带一个状态枚举——没有任何事件携带屏幕文本。server 是状态仓库,不是内容路由器:它推送信号,内容必须显式拉取。
把整条链画出来:
graph LR
P["子 agent 进程<br/>(claude code / codex / 任意 CLI)"] -->|stdout 字节流| PTY["子 pane 的 PTY<br/>(server 拥有)"]
PTY -->|逐字节终端仿真<br/>隐藏 pane 也持续解析| TS["该 pane 的终端状态<br/>屏幕 + scrollback<br/>(server 内存,不是 token)"]
TS -->|渲染| TUI["TUI 客户端<br/>(人旁听,随时介入)"]
TS -->|"事件流:只推信号<br/>pane_id + 状态枚举,无内容"| EV["EventHub<br/>(512 条环形缓冲)"]
TS -->|"agent read / pane read<br/>显式拉取,有界(默认 80 行,上限 1000)"| O["主 pane 的<br/>orchestrator agent"]
EV -.->|"状态事件唤醒<br/>(agent wait 在此阻塞)"| O
这就是对开篇问题的准确回答:每个 pane 的输出天然汇聚成 server 内存里的实时终端状态,主 pane 的 agent 只是通过同一套 JSON API 按需读这份状态。TUI、CLI、远程客户端都是平等的客户端——这也正是 Herdr 自己的架构守则:“pane 的终端内容、agent 状态是 server 拥有的 runtime 事实,TUI 只是其中一个客户端”。
二、对比:Claude Code 的 subagent 派发
Claude Code 的 subagent(见官方文档)是另一种完全不同的实现。一句话概括差异:
Claude Code 的 subagent 是同进程内的”结构化调用”——父 agent 拿到的是结论;Herdr 是跨进程的”终端表面协调”——父 agent 观察的是屏幕,像人一样。
两种拓扑放在一起看最直观:
graph TB
subgraph CC["Claude Code subagent:进程内结构化调用"]
direction TB
PA["父 agent 循环<br/>(同一 harness 进程)"] -->|"Agent 工具调用<br/>fork 型会继承父的全部历史"| SA["子 agent 循环<br/>(独立 context window)"]
SA -->|"最终报告 = 返回值<br/>(唯一的回流通道)"| PA
SA -.- MID["中间工具输出<br/>留在子 context 内<br/>对父不可见(harness 强制)"]
end
subgraph HD["Herdr pane 派发:跨进程终端表面协调"]
direction TB
O2["orchestrator<br/>(pane A 的进程)"] -->|"agent prompt<br/>子 agent 收到的全部输入<br/>只有这一条 prompt"| S2["herdr server<br/>(外置终端状态)"]
S2 -->|写入 PTY 输入| A2["子 agent CLI<br/>(pane B 的进程)"]
A2 -->|stdout → server 终端仿真| S2
end
O2 ==>|"agent read(有界拉取)<br/>agent wait(状态事件)"| S2
| 维度 | Claude Code subagent | Herdr pane 派发 |
|---|---|---|
| 被派发者是什么 | 同一进程里的另一个 LLM 对话循环(独立 context window) | 真实 OS 进程,跑在真实 PTY 里的完整 agent CLI |
| 通信边界 | 类型化的工具返回值:最终报告即函数返回值 | 终端屏幕:agent prompt 发文本,agent read 读屏幕 |
| 中间过程可见性 | 刻意对父 agent 不可见(这是特性:文件转储不进父 context) | 持续可见,任何时刻可读快照、wait 事件 |
| 状态放在哪 | 会话内存,随会话消失 | server 外置的终端状态,客户端断开也存活 |
| 谁能观察 | 只有 harness(人只能看 spinner 和最终通知) | 人、TUI、CLI、远程客户端都是平等观察者 |
| 被派发者异构性 | 只能是同种 agent(同一 harness 的模型循环) | 任何吃终端的程序:Claude Code、Codex、随便什么 CLI |
| 同步模型 | 完成时推送一条通知 | 拉式查询 + EventHub 状态事件 |
三个最值得展开的点:
语义边界 vs 通用表面。 Claude Code 的 subagent 返回结构化文本,父 agent 零解析成本拿到结论——但前提是双方都是同一 harness。Herdr 的接口是终端屏幕,这个接口”没有类型”,所以它需要一整个检测引擎(每个 agent 一份 TOML manifest,把屏幕状态分类成 idle/working/blocked/done/unknown)去解释屏幕。代价是解释层,收益是通用性——任何 agent CLI 不用改一行代码就能被编排。配合集成 hook 让 agent CLI 主动上报状态,这条路还能逐步从”看屏幕猜”升级为”听 agent 说”。
中间状态外置 vs 内化。 Claude Code subagent 的中间输出留在子 context 里,本质是 context window 管理术;Herdr 把每个 pane 的”此刻屏幕”外置成 server 里的共享事实。这带来一个 Claude Code 做不到的性质:人可以随时介入某个被派发的 pane——看它在干嘛、手动回答它的 approval 弹窗,然后 agent 再接管。因为人和 agent 用的是同一个表面。
对称性。 Herdr 里编排者自己也跑在一个 pane 里,任何 pane 都可以编排其他 pane,没有固定的父子层级;Claude Code 的层级是 harness 定死的。
两者还可以组合:跑在 Herdr pane 里的 Claude Code 实例,可以装上 Herdr 的 agent skill(npx skills add herdrdev/herdr --skill herdr -g),用 CLI 再开几个 pane 派给其他 agent 实例——外层用终端表面做跨进程协调,内层保留进程内的 subagent 能力。
三、关键追问:上下文管理怎么办?
subagent 机制本来有两个作用:一是按任务选择合适的模型能力,二是上下文管理——尤其是保护主 agent 的 context 不被噪音淹没。那么 Herdr 这种方式,是不是只做到了第一点,第二点仍是瓶颈?
答案是:Herdr 完整保留了”隔离”性质,丢掉的是”压缩”性质。
隔离是怎么保留的
被派发 agent 是独立进程、独立 PTY、独立 agent CLI 实例,有自己独立的 context window。它工作 20 分钟读 50 个文件、跑测试、吐 10MB 输出,这些字节的完整去向是:
子进程 stdout → 子 pane 的 PTY → server 解析 → 该 pane 自己的终端状态/scrollback
终点是 server 内存里的字节缓冲,不是任何 agent 的 token。这不是”通常不会泄漏”,而是物理上没有泄漏路径:两边不共享进程、不共享内存、不共享 context;事件系统只推信号不推内容;内容进入 orchestrator context 的唯一入口是一次显式的、有界的 pane read。
走一遍生命周期,量化 orchestrator 的 context 消耗:
| 时刻 | 发生什么 | orchestrator context 增量 |
|---|---|---|
| t0 | agent prompt reviewer "..." | prompt 本身(自己写的,几百 token) |
| t0–t20m | reviewer 读 50 个文件、跑测试、吐 10MB | 0(阻塞在 server 侧等事件) |
| t20m | wait 返回 | 一个状态 JSON(几十 token) |
| t20m+ | agent read --lines 120 | ≤120 行,可选 |
画成时序图,可以清楚看到”工作期间零读取”发生在哪里:
sequenceDiagram
autonumber
participant O as 主 pane 的 orchestrator
participant S as herdr server
participant A as 子 pane 的 agent(reviewer)
O->>S: agent prompt reviewer … --wait
Note right of O: context +prompt(自己写的文本)
S->>A: 文本 + 编码回车写入 PTY
Note over S: 5 秒活动门:必须先观察到 working,<br/>不相关的 idle 不算数
loop 工作期间(约 20 分钟)
A->>S: stdout 字节流(读 50 个文件/跑测试,累计约 10MB)
Note over S: 逐字节终端仿真 → 该 pane 的终端状态
end
Note over O: context 增量 = 0<br/>(阻塞发生在 server 侧等状态事件)
S-->>O: settled 状态事件(idle / done / blocked)
Note right of O: context +状态 JSON(几十 token)
O->>S: agent read --source recent-unwrapped --lines 120
S-->>O: 屏幕快照(≤120 行)
Note over O: 总暴露量 ≈ prompt + 状态 + 一次有界读取
主 agent 对整个 10MB 探索的暴露量:prompt + 状态 + 一次有界读取——和 Claude Code”只有最终报告回流”是同一个量级。
而且有四个维度上,这种隔离比进程内边界更强:
- 下行也纯净。 Claude Code 的 fork 型 subagent 会继承父的全部对话历史,父的噪音会倒灌给子;Herdr 的派发只有 fresh-start 形式,子 agent 收到的全部输入就是那一条 prompt。双向零污染。
- 观察无副作用。 读取走
ReadIntent::Passive,不改变 pane 的 seen 状态(文档明确:focus 才 mark seen,read 不会)。观察者不干扰被观察者。 - 隔离在死亡之外存活。 orchestrator 崩溃、被压缩、甚至被人换掉,子 pane 的状态原封不动地在 server 里;新的 orchestrator attach 后
agent get一次就恢复态势。进程内 subagent 的 session 一死,状态全灭。 - 第三方旁听。 人可以随时在 TUI 里看子 pane 的全程而不进入任何 agent 的循环。
真正丢掉的是什么
- 返回通道没有内建压缩。 Claude Code subagent 的返回值是子模型自己写的报告——压缩是免费的(它反正要生成)。Herdr 的
agent read返回的是 raw 屏幕尾巴,信噪比取决于被派发 agent 的输出习惯,spinner、UI chrome 都混在里面。 - 边界是约定不是架构。 Claude Code 里中间转储不可能进父 context(harness 两端都拥有);Herdr 里一个不守纪律的 orchestrator 每 30 秒读 1000 行,照样把自己撑爆。
- Steering 循环有真实成本。 被派发 agent 卡在 approval 弹窗时,每次介入都要先读屏幕才能决策;这类多轮交互的累积噪音,Claude Code 是在 subagent 自己的循环里消化掉的。
核心经济学一句话:噪音以字节形式住在 server 里(便宜),只有经过显式、有界、拉式的转换才变成 token(贵)。 Claude Code 把隔离和压缩打包在同一个 harness 边界里出售;Herdr 把隔离做成了架构,把压缩降格成了纪律。
四、读取纪律:给架构缺口打的补丁
上一节的”纪律”不是虚指,它被明明白白写进了 Herdr 的 agent skill 文档(skills/herdr/SKILL.md)。这份文档教的就是一条从派发到回收的完整流水线:
1. 压缩在 prompt 时刻就发生了
skill 里的示例 prompt 是:
"Review the current diff and report only actionable findings."
注意措辞——“report only actionable findings”。纪律的第一条不是怎么读,而是派发时就要求子 agent 产出一个有界的、结论形态的、放在末尾的报告。这是个双方契约:子 agent 被告知把结论做成”屏幕形状”(终态、简短、在最后),orchestrator 才能用”读尾部”来收割。没有这个约束,屏幕尾部就只是”最后发生的事”,不是结论。
2. 同步全部在 server 侧,working 期间零读取
agent prompt --wait 阻塞等 settled 状态,工作期间 orchestrator 不做任何轮询读取。真正被纪律排除的不是”未完成状态”,而是 working 状态——TUI agent 工作时在备屏上刷 spinner 和部分输出,此刻读到的快照既是噪音又马上作废;settled 之后屏幕停止变化,尾部才是稳定的定格。状态是便宜的门闸(几十 token),内容是贵的货物,先状态后内容本质是给内容读取加信号门。
wait 的语义还专门防了一个坑:提交后必须先观察到 working 活动(最多等 5 秒),不相关的 idle 不算数。没有这个门,一个本来就没在干活的 agent 会立刻返回 idle,你随后读到的尾部是上一轮的旧输出而不是这轮 prompt 的结论。
3. 读取发生在三类 settled 时刻,用途不同
- idle/done → 收割读取:读尾部拿
actionable findings; - blocked → 诊断读取:agent 停在 approval/question 弹窗,读屏幕是为了看懂弹窗在问什么(文档还要求先问人再代答);
- timeout/stalled → 故障诊断读取:文档明确”timeout 不证明 prompt 没送达,不要盲目重新提交”——这条是防噪音放大,也防任务被重复执行。
整套门控行为画成流程图:
graph TD
ST["发送 prompt<br/>(内含结论形态要求:<br/>report only actionable findings)"] --> W["server 侧阻塞等待<br/>working 期间零读取"]
W -->|settled 状态事件| G{"哪个 settled 状态?"}
G -->|idle / done| R1["收割读取:<br/>read 尾部拿结论"]
G -->|blocked| R2["诊断读取:看懂弹窗,<br/>先问人再决定是否代答"]
G -->|timeout / stalled| R3["故障诊断读取:get + read,<br/>不盲目重新提交"]
R2 -->|send-keys 或人工介入后| W
R3 -->|确认未送达才重提| W
R1 --> ESC{"一次有界读取<br/>拿到完整结论?"}
ESC -->|否,扩大 recent 读取仍不够| F["文件路径协议(仅兜底):<br/>子 agent 把完整报告写成 Markdown,<br/>屏幕只回路径,再直接读文件"]
4. 升级阶梯:读不动就换文件协议
最有想象力的一条:如果更大的屏幕读取仍找不到完整结论,让子 agent把完整报告写成 Markdown 文件、屏幕上只回一个文件路径,然后 orchestrator 直接读文件。但文档严格规定这只是 fallback:“do not request file output in the initial prompt”。常规情况交互成本最低,只有屏幕确实装不下结论时,才付出”两条命令 + 文件读取”的成本——而这本质上是把压缩步骤手工塞回被派发 agent 的 turn 里,重建 Claude Code 免费获得的东西。
整套纪律的形状:prompt 时压缩 → server 侧同步 → 先状态后内容 → 一次尾部有界读取 → 兜底换文件协议。它之所以存在,恰恰因为架构没有强制它——skill 文档花大量篇幅教读取纪律,正是架构缺的那半块被产品文档填补的痕迹。
结语
回到开篇的问题:Herdr 里主 agent 之所以能”实时”看到其他 pane 的输出,是因为所有终端输出天然汇聚到一个持续做终端仿真的 server,可见性变成了对共享状态的按需查询,而不是消息转发。
再往深一层,这个设计展示了一组干净的取舍:用 server 外置状态,Herdr 买到了隔离、持久性、人的可介入性和对任意 agent CLI 的通用性;代价是返回通道的压缩不再内建,只能靠 skill 文档把纪律补上。Claude Code 的进程内 subagent 正好是镜像:强制边界同时买到隔离和压缩,代价是失去中间过程的可观察性、对异构 agent 的通用性,以及 session 之外的生命力。
一个是”结论作为返回值”,一个是”屏幕作为共享内存”。两种范式的差异,大概率会在未来的多 agent 基础设施里继续共存下去——而且如前所述,它们本来就能叠着用。(工具层面 Herdr 与 session-share 的分工与配合,之前写过一篇《Herdr 和 session-share》,可作旁参。)
Sources
- Herdr 官网与文档
- agent skill 文档
- herdrdev/herdr 源码仓库 — Apache-2.0;文中引用的 src/pane.rs、src/app/api/panes.rs、src/app/api_helpers.rs、src/api/wait.rs、src/api/event_hub.rs、skills/herdr/SKILL.md 均在此仓库
- Claude Code subagents 官方文档