跳到主要内容

数据截至 (上游 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 五个状态之一。
  • 一套 herdr CLI 和一套 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.rssrc/main.rs:810;auto_detect_launch
服务端主循环持有 App 状态、所有 PTY、所有客户端连接,单循环驱动渲染与事件src/server/headless.rsHeadlessServer src/server/headless.rs:287
终端内核每个 pane 一个 PTY + 一份 Ghostty VT 终端,解析字节、维护屏幕src/pane/terminal.rssrc/ghostty/mod.rsTerminal src/ghostty/mod.rs:779
终端事实层纯数据的终端状态(agent 身份、状态、元数据),不碰异步和 PTYsrc/terminal/state.rsTerminalState 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.rssrc/protocol/render_ansi.rsClientRenderState src/server/render_stream.rs:13
持久化与交接会话落盘、崩溃恢复、换二进制时把 PTY 文件描述符递给新服务端src/persist/src/server/handoff.rsHandoffManifest src/server/handoff.rs:34

2.3 主线走一遍:一次「agent 卡住了」是怎么被知道的

不进代码,先把这条链走通。这是理解 herdr 最核心的一条路径:

  1. PTY 出字节。 agent 在 pane 里打印了一个 "Do you want to proceed? ❯ 1. Yes"。字节从 PTY 主端读出来,喂进这个 pane 自己的 Ghostty VT 引擎,屏幕状态更新。
  2. 检测循环醒来。 每个 pane 有一个独立的 tokio 任务在滴答(识别出 agent 时 300ms 一跳,src/pane.rs:2163)。它先看进程:pane 的前台进程组里跑的是不是一个已知 agent。
  3. 取屏幕尾巴。 它只读屏幕底部一屏的纯文本,不是整个滚动缓冲——这条约束是刻意的,细节和锚点见 §4.1。
  4. 按规则匹配。 这段文本被丢进该 agent 的屏幕规则表(TOML),按优先级挑出唯一胜出的规则,得出 blocked,并且标记「这是屏幕上肉眼可见的阻塞证据」。
  5. 仲裁与发布。 屏幕结论要和别的信息源打架:agent 官方集成 hook 报的状态、进程是否退出、OSC 标题里的转圈符号。仲裁后写进终端事实层,发出一个状态变更事件。
  6. 两路扩散。 一路进渲染:侧边栏那颗小圆点变色,后台工作区还会响一声;另一路进事件总线:所有正 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 指挥另一个 agentJSON 方法表、事件订阅、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_bodyafter_last_horizontal_ruleosc_titlebottom_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 的确认框,那就是卡住了。

4.8 idledone 是同一个状态的两种呈现

对外 API 的状态有五个,但 Done 并不是一个独立的内部状态——它就是 Idle 加上「这个 tab 还没被人看见过」:

// src/app/api_helpers.rs:103-108,真实源码片段
match (state, seen) {
(AgentState::Idle, false) => AgentStatus::Done,
(AgentState::Idle, true) => AgentStatus::Idle,
...
}

这一行把「做完了但你还没看」和「闲着等你输入」分开了,而代价只是多带一个布尔。注意 CLI 的读取不算"看见",只有聚焦才算。

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

用法: 行号会随上游漂移,符号名一般不会——用符号名 grep 定位最稳。

主题文件路径符号名锚点
入口与模式分流src/main.rsmainsrc/main.rs:519
探测/拉起服务端后接入src/server/autodetect.rsauto_detect_launchis_server_listening_atsrc/server/autodetect.rs:290
服务端主循环src/server/headless.rsHeadlessServerrunrun_serversrc/server/headless.rs:287,545,5038
客户端主循环src/client/mod.rsrun_clientrequested_render_encodingsrc/client/mod.rs:704
客户端 socket 路径与权限src/server/socket_paths.rsclient_socket_pathSOCKET_PERMISSION_MODEsrc/server/socket_paths.rs:12,23
二进制线协议src/protocol/wire.rsPROTOCOL_VERSIONClientMessageServerMessageFrameDatasrc/protocol/wire.rs:16,343,527,661
PTY actorsrc/pty/actor/unix.rssrc/pty/backend.rsPtyIoActorPtyIoActorHandlesrc/pane.rs:24
Ghostty VT 绑定src/ghostty/mod.rssrc/ghostty/bindings.rsTerminalread_text_screensrc/ghostty/mod.rs:779,1204
VT 引擎的构建方式build.rsvendor/libghostty-vt.vendor.jsonzig_targetbuild.rs:6,63
pane 终端封装src/pane/terminal.rsdetection_textghostty_detection_textDEFAULT_DETECTION_ROWSsrc/pane/terminal.rs:40,1950,2490
pane 运行时与检测任务src/pane.rsPaneRuntimeTICK_IDENTIFIEDsrc/pane.rs:1033,2165
终端纯状态src/terminal/state.rsTerminalStateHookAuthorityrecompute_effective_statesrc/terminal/state.rs:18,120,2125
终端运行时外壳src/terminal/runtime.rsTerminalRuntimesrc/terminal/runtime.rs:17
agent 与状态枚举src/detect/mod.rsAgentAgentStateAgentDetectionSCREEN_MANIFEST_AGENTSsrc/detect/mod.rs:11,24,43,94
进程侧识别src/detect/mod.rsidentify_agent_in_jobforeground_process_group_idsrc/detect/mod.rs:221,334
屏幕规则表引擎src/detect/manifest.rsevaluate_loaded_manifestregionload_manifest_uncachedDetectionExplainsrc/detect/manifest.rs:28,415,568,1255
屏幕规则表数据src/detect/manifests/*.toml[[rules]]priorityregionsrc/detect/manifests/claude.toml
规则表远程更新src/detect/manifest_update.rsMANIFEST_ENGINE_VERSIONsrc/detect/manifest_update.rs:15
检测发布决策(纯函数)src/pane/agent_detection.rsshould_skip_idle_screen_scanPendingIdleConfirmationdecide_screen_detection_publishsrc/pane/agent_detection.rs:24,91,238
JSON API 方法表src/api/schema.rsMethodRequestsrc/api/schema.rs:34,45
API 服务端src/api/server.rsstart_server_with_capabilitiessrc/api/mod.rs:11
阻塞等待语义src/api/wait.rswait_for_resolved_agentagent_wait_matcheswait_for_outputsrc/api/wait.rs:22,348,540
事件总线与订阅src/api/event_hub.rssrc/api/subscriptions.rsEventHubActiveSubscriptionsrc/api/mod.rs:9
对外状态映射src/app/api_helpers.rspane_agent_statussrc/app/api_helpers.rs:96
CLI 命令面src/cli/agent.rssrc/cli/spec.rsrun_agent_commandsrc/cli/agent.rs:12
每客户端渲染基线src/server/render_stream.rsClientRenderStateprepare_framesrc/server/render_stream.rs:13,65
ANSI 差量编码src/protocol/render_ansi.rsBlitEncoderEncodedBlitfinal_sync_output_endsrc/protocol/render_ansi.rs:39,45,57
布局树src/layout.rsNodePaneIdsrc/layout.rs:11,73
会话快照src/persist/snapshot.rssrc/persist/restore.rsSessionSnapshotSNAPSHOT_VERSIONrestoresrc/persist/snapshot.rs:12,16
热交接src/server/handoff.rssrc/handoff_runtime.rsHandoffManifestHandoffRuntimeStateMAX_FDS_PER_HANDOFFsrc/server/handoff.rs:26,34
自更新与 --handoffsrc/update.rsparse_self_update_argsserver_supports_live_handoffsrc/update.rs:835,1100
远程接管src/remote/attach.rssrc/remote/host_unix.rsrun_remoterun_remote_client_bridgesrc/remote/attach.rs:162,2126
agent 官方集成src/integration/mod.rssrc/integration/claude_settings.rsinstall_targetfull_lifecycle_hook_authoritysrc/detect/mod.rs:295;src/integration/claude_settings.rs:61