数据截至 (上游 commit 3667151744e3)
herdr — 架构与原理
30 秒导读: herdr 是一个用 Rust 写的终端工作区管理器,专门给「在终端里跑的 AI 编码 agent」当运行时。它和 tmux 最大的不同有两点:第一,终端仿真和 PTY 全部由一个常驻后台服务端独占,你看到的 TUI 只是个收帧的瘦客户端;第二,它把「这个 pane 里的 agent 现在是在干活、还是卡在等你点确认、还是闲着」做成了运行时的一等事实,通过 JSON socket 暴露出来,于是 agent 可以用它来调度另一个 agent。
1. 这是什么(零基础也能懂)
一句话定义: herdr 是一个终端复用器(把一个终端窗口切成多个 pane、多个 tab、多个工作区的工具),但它是为「同时跑一堆 AI 编码 agent」这件事重新设计的。
它解决的是什么麻烦
假设你在终端里同时开了六个 Claude Code / Codex / Cursor 的会话,分别改六个仓库。日常的痛点是这样几个:
| 痛点 | tmux 之类工具下的现状 |
|---|---|
| 谁停下来了 | 得一个一个 pane 翻过去看,才知道哪个在等你点 "yes" |
| 关掉终端 | 复用器能保住会话,但没人告诉你哪个 agent 做完了 |
| 让 agent 互相协作 | 没有稳定接口,只能靠 tmux send-keys 往里塞字符,盲发盲收 |
| 升级工具本身 | 升级复用器基本意味着重启,跑着的活儿断掉 |
herdr 的回答是:把这些都做成运行时能回答的问题,而不是靠你用眼睛看。
它能做什么(功能层面)
- 工作区 / tab / pane 三层布局,鼠标点、拖、分屏,和 tmux 风格的前缀键(默认
ctrl+b)并存。 - 自动识别 pane 里跑的是哪个 agent,并持续判定它处于 working / blocked / idle / done / unknown 五个状态之一。
- 一套
herdrCLI 和一套 JSON socket API:开 pane、发提示词、读输出、阻塞等到某个 agent 变成 blocked 为止。 - 服务端常驻:关掉终端、断网、甚至换掉 herdr 二进制,pane 里的进程都还活着。
herdr --remote <ssh-target>:从本机接管远端机器上的那个服务端。
用起来什么样
先看人怎么用:
herdr # 没有 服务端就拉起一个,然后作为客户端贴上去
# ctrl+b v 竖切一个 pane,在里面跑 claude
# ctrl+b q 断开;终端关掉也没事,再敲一次 herdr 就回来了
再看 agent 怎么用——这才是 herdr 的重点。下面这段是一个 agent 在自己的 pane 里,指挥另一个 agent 干活:
# 在已有的 shell pane 里起一个叫 reviewer 的 agent
herdr agent start reviewer --kind claude --pane w1:p2
# 给它发提示词,并且一直等到它真的处理完(或者卡住了要人)
herdr agent prompt reviewer "review the diff in src/detect" --wait
# 也可以只等状态:等到它 blocked(需要人拍板)再叫我
herdr agent wait reviewer --until blocked --timeout 600000
herdr agent wait 这类命令背后是真的事件订阅 + 状态轮询,不是 sleep 猜时间(src/api/wait.rs:348 wait_for_resolved_agent)。
一句话直觉
把 herdr 想成「终端界的 systemd + 显示服务器合体」: 终端的真身(PTY 和屏幕状态)只有服务端那一份,你的 TUI 只是一块显示器;而「agent 现在什么状态」被当成系统级别的可查询事实,像 systemctl status 一样能问、能等。
2. 顶层全景(它大概怎么转)
2.1 进程与连接
先看这张图。从上往下读:上面三种角色都是客户端,中间那个方框是唯一拥有真实终端的进程,最下面是被托管的真实程序。
你的终端窗口 另一台机器 (ssh) 某个 agent 的 shell
herdr 瘦客户端 herdr --remote herdr agent wait ...
│ │ │
│ 二进制帧协议 │ 二进制帧(走 ssh 管道) │ JSON 行协议
│ herdr-client.sock │ │ herdr.sock
└─────── ────┬───────────┘ │
▼ ▼
┌──────────────────────────────────────────────────────────────┐
│ herdr server(常驻后台,进程内拥有一切) │
│ ① 终端内核: PTY + 内嵌 Ghostty VT 引擎 + 滚动缓冲 │
│ ② 状态仲裁: 进程探测 / 屏幕规则 / OSC / 集成 hook 打架的地方 │
│ ③ 渲染: 画出整帧,再按每个客户端的编码切片下发 │
└──────────────────────────────────────────────────────────────┘
│ PTY 主端读写
▼
claude · codex · cursor · opencode · 普通 shell …
关键的一句话:herdr 不包装、不代理这些 agent 的 CLI,它只是 拥有它们的终端。 agent 本身照常跑,herdr 从外面看屏幕、看进程、听 OSC 序列,反推出它的状态。
2.2 部件一句话职责
| 部件 | 干什么 | 主要文件 | 锚点 |
|---|---|---|---|
| 启动分流 | herdr 无子命令时:探测服务端 → 没有就 daemon 化拉一个 → 作为客户端接入 | src/server/autodetect.rs | src/main.rs:810;auto_detect_launch |
| 服务端主循环 | 持有 App 状态、所有 PTY、所有客户端连接,单循环驱动渲染与事件 | src/server/headless.rs | HeadlessServer src/server/headless.rs:287 |
| 终端内核 | 每个 pane 一个 PTY + 一份 Ghostty VT 终端,解析字节、维护屏幕 | src/pane/terminal.rs、src/ghostty/mod.rs | Terminal src/ghostty/mod.rs:779 |
| 终端事实层 | 纯数据的终端状态(agent 身份、状态、元数据),不碰异步和 PTY | src/terminal/state.rs | TerminalState src/terminal/state.rs:120 |
| 检测引擎 | 从进程名 + 屏幕尾部文本 + OSC 标题,判定 agent 与其状态 | src/detect/ | detect_agent_with_osc src/detect/mod.rs:266 |
| 控制面 | 92 个 JSON 方法 + herdr CLI 包装,含订阅与阻塞等待 | src/api/、src/cli/ | Method src/api/schema.rs:45 |
| 渲染管线 | 服务端渲染成语义帧,再按客户端能力发全帧或 ANSI 差量 | src/server/render_stream.rs、src/protocol/render_ansi.rs | ClientRenderState src/server/render_stream.rs:13 |
| 持久化与交接 | 会话落盘、崩溃恢复、换二进制时把 PTY 文件描述符递给新服务端 | src/persist/、src/server/handoff.rs | HandoffManifest src/server/handoff.rs:34 |
2.3 主线走一遍:一次「agent 卡住了」是怎么被知道的
不进代码,先把这条链走通。这是理解 herdr 最核心的一条路径:
- PTY 出字节。 agent 在 pane 里打印了一个 "Do you want to proceed? ❯ 1. Yes"。字节从 PTY 主端读出来,喂进这个 pane 自己的 Ghostty VT 引擎,屏幕状态更新。
- 检测循环醒来。 每个 pane 有一个独立的 tokio 任务在滴答(识别出 agent 时 300ms 一跳,
src/pane.rs:2163)。它先看进程:pane 的前台进程组里跑的是不是一个已知 agent。 - 取屏幕尾巴。 它只读屏幕底部一屏的纯文本,不是整个滚动缓冲——这条约束是刻意的,细节和锚点见 §4.1。
- 按规则匹配。 这段文本被丢进该 agent 的屏幕规则表(TOML),按优先级挑出唯一胜出的规则,得出
blocked,并且标记「这是 屏幕上肉眼可见的阻塞证据」。 - 仲裁与发布。 屏幕结论要和别的信息源打架:agent 官方集成 hook 报的状态、进程是否退出、OSC 标题里的转圈符号。仲裁后写进终端事实层,发出一个状态变更事件。
- 两路扩散。 一路进渲染:侧边栏那颗小圆点变色,后台工作区还会响一声;另一路进事件总线:所有正
herdr agent wait --until blocked的调用者立刻返回。
第 4、5 步是整个项目工程含量最高的地方,单独一章讲。
3. 阅读地图
建议顺序就是下面的顺序:先搞清楚「谁拥有终端」,再看「终端本身怎么做的」,然后才进核心机制。
| 顺序 | 章节 | 讲什么 | 读完你能回答 |
|---|---|---|---|
| 1 | 进程模型:一个常驻服务端 + 一群瘦客户端 | 启动分流、两个 socket、协议版本、客户端握手与前台客户端选举 | 为什么关掉终端 agent 还活着 |
| 2 | 终端内核:PTY、内嵌 Ghostty VT、以及 state/runtime 的切分 | portable-pty 的 actor、vendored libghostty-vt 的 FFI、TerminalState 与 TerminalRuntime 为何要拆 | 一个 pane 的屏幕到底存在哪 |
| 3 | 核心机制:怎么知道 agent 是在干活、还是卡住了 | 进程识别、屏幕规则表 DSL、region 切法、优先级仲裁、抖动抑制 | 为什么它敢说 "blocked" |
| 4 | 控制面:让 agent 用 CLI 和 socket 指挥另一个 agent | JSON 方法表、事件订阅、agent.wait 的身份校验、注入给 pane 的环境变量 | agent 怎么安全地编排 agent |
| 5 | 渲染管线:服务端画好帧,再决定发多少字节给谁 | 语义帧 vs ANSI 差量、BlitEncoder、同步输出、多客户端扇出的成本控制 | 远程连接为什么不卡 |
| 6 | 活得久:落盘恢复、换二进制不断线、跨机器接管 | session 快照、agent 会话恢复、fd 交接式热升级、ssh 远程接管 | 升级 herdr 为什么不用杀进程 |
如果你只想看一个点:第 3 章是这个项目的灵魂,第 4 章是它区别于所有其他复用器的产品面。
4. 巧妙之处(值得带走的技术)
4.1 状态判定不看用户视口,只看屏幕底部一屏
妙在哪: 用户可以随手往上滚屏。如果检测读的是「当前可见区域」,一滚就误判。herdr 的检测文本固定取终端底部 rows 行,与视口无关(ghostty_detection_text,src/pane/terminal.rs:2542;缺省 24 行常量在 src/pane/terminal.rs:41)。这条约束还被一条测试钉死(detection_text_stays_at_bottom_when_viewport_is_scrolled,src/pane/terminal.rs:5134)。
后面第 2、3、4 章都会用到这条约束,锚点以本节为准,不再重复列。
4.2 检测规则是数据,不是代码
每个 agent 的识别规则写在一份 TOML 规则表里,而不是硬编码的 if-else(src/detect/manifests/claude.toml 等 20 份,Agent::SCREEN_MANIFEST_AGENTS src/detect/mod.rs:94)。
一条规则长这样,region 说明在屏幕的哪一块找、priority 决定和别的规则打架时谁赢:
[[rules]]
id = "generic_permission_prompt"
state = "blocked"
priority = 840
region = "after_last_horizontal_rule"
visible_blocker = true
contains = ["do you want to proceed?", "esc to cancel"]
三个好处叠在一起:
- 上游 agent 改 UI 时,不用改 Rust。 规则表可以被本地覆盖文件顶掉,也可以从 herdr.dev 拉远程更新(
load_manifest_uncached,src/detect/manifest.rs:568)。 - region 是有语义的切法,不是"取最后 N 行"了事:
prompt_box_body、after_last_horizontal_rule、osc_title、bottom_non_empty_lines(5)各有实现(region,src/detect/manifest.rs:1255)。 - 匹配过程可解释。
herdr agent explain <pane> --json会把每条规则的命中与否、证据、region 字节数全吐出来(DetectionExplain,src/detect/manifest.rs:28)。
优先级仲裁本身只有几行:遍历所有规则,记住优先级最高的那条命中项(src/detect/manifest.rs:443-446)。
4.3 「working → idle」要连续确认三次才认
妙在哪: agent 干活时屏幕经常有一瞬间什么都不显示,直接判 idle 会让侧边栏疯狂闪、还会让 agent wait 提前返回。
herdr 的做法是:只有 working → 纯 idle(且屏幕上没有可见的 idle 证据)这一种转移会被压住,要么连续确认 3 次,要么超过 700ms 上限才放行(PendingIdleConfirmation::should_hold_working_to_idle,src/pane/agent_detection.rs:39;常量在 src/pane/agent_detection.rs:7-9)。
关键在于它压得很窄: 一旦屏幕上有肉眼可见的 idle 提示框、或者是进程真的退出了、或者 agent 变了,立刻放行,不拖延。
4.4 稳定态下直接不扫屏
妙在哪: 检测是「每 pane × 每 300ms」的乘法开销,15 个 pane 就是每秒 50 次全屏文本导出。
herdr 给 PTY 读取加了一个原子计数器 detection_content_seq,只要有非空字节进来就 +1(observe_detection_content_change,src/pane/agent_detection.rs:319)。当 pane 已经是 idle、且计数器没变过,这一跳直接 continue,连屏幕文本都不取(should_skip_idle_screen_scan,src/pane/agent_detection.rs:91)。
4.5 渲染在服务端做完,只把差量发出去
服务端把整个 UI 渲染成一份语义帧 FrameData(逐 cell 的字符、前景/背景色、修饰位,src/protocol/wire.rs:527),然后按客户端协商的编码分两条路:
| 编码 | 发什么 | 谁用 | 锚点 |
|---|---|---|---|
SemanticFrame | 整份 FrameData;和上一帧完全相同就干脆不发 | 本地客户端(默认) | src/server/render_stream.rs:67-76 |
TerminalAnsi | 服务端先和上一帧做 cell 级 diff,只发变化处的 ANSI 字节 | 远程 ssh 客户端 | BlitEncoder src/protocol/render_ansi.rs:57 |
远程模式是通过环境变量把客户端切到 ANSI 差量的(src/remote/attach.rs:2126)。差量帧还被整体包进同步输出序列 CSI ?2026 h/l,避免半帧被看见(src/protocol/render_ansi.rs:1-27 的模块注释列出了完整策略)。
4.6 换二进制不杀进程:把 PTY 的文件描述符递过去
herdr update --handoff 走的不是「停服务端 → 起新的 → 恢复快照」,而是:老服务端把会话快照和每个 pane 的 PTY 主端 fd一起通过 Unix socket 传给新进程(HandoffManifest,src/server/handoff.rs:34;逐 pane 的运行时状态 HandoffRuntimeState,src/handoff_runtime.rs:13)。
它诚实地划了边界:交接保留长命的东西(PTY、进程、agent 身份、插件与会话元数据),不保留短命的协调状态(在途请求、订阅、等待、客户端 socket)——后者由客户端重连 后自己重试。这段边界写在 src/handoff_runtime.rs:4-10 的文档注释里。
护栏也在代码里:一次交接最多 64 个 fd,每个 pane 最多回放 8KB 历史(src/server/handoff.rs:26-28)。
4.7 官方集成只报「身份」,不报「状态」——除了少数几个
这是一个容易看反的设计。herdr 会往 agent 自己的配置里装 hook 脚本,但对 Claude Code 而言,现在只装了一个 SessionStart 钩子,作用是上报会话身份(用于重启后恢复对话),并不接管状态判定(install,src/integration/claude_settings.rs:61-78;脚本里非 session 动作直接退出,src/integration/assets/claude/herdr-agent-state.sh:15-18)。
真正拥有「全生命周期状态权威」的只有一小撮(pi / omp / mastracode / opencode / kilo / kimi,full_lifecycle_hook_authority,src/detect/mod.rs:295)。其余 agent 一律靠屏幕检测。
而且即使 hook 有权威,屏幕上肉眼可见的阻塞证据仍然能盖过它——仲裁函数第一句就是这个判断(recompute_effective_state,src/terminal/state.rs:2133)。理由很实在:hook 说"我在干活",但屏幕上明晃晃摆着一个等你按 Y 的确认框,那就是卡住了。