数据截 至 (上游 commit 3667151744e3)
核心机制:怎么知道 agent 是在干活、还是卡住了
30 秒导读: herdr 要在侧边栏给你看"哪个 agent 停下来等你了"。但 agent 是个跑在 PTY 里的 黑盒,它不会告诉你自己的状态。herdr 的答案是三条路一起走:看屏幕(TOML 规则表匹配终端底部 文本)、看 OSC 标题(agent 写给终端标题栏的 spinner)、听 hook(装进 agent 配置里的回调 脚本主动上报),再按一套固定的权威顺序仲裁出一个状态。
同组其它章:进程模型 · 终端内核 · 控制面 · 渲染管线 · 落盘与接管。
1. 这一章要解决的那个小问题
问题一句话: 你开了 15 个 pane,每个里面跑着一个 coding agent。哪个在忙、哪个在等你按 y、 哪个已经跑完了?
为什么难: agent 是个终端 TUI 程序。它唯一对外输出的东西,是一坨带 ANSI 转义序列的字符。 它不导出状态、不开端口、没有标准协议。你想知道"它卡住了没有",只能:
- 去猜那坨字符——但字符会滚动、会被用户翻页、会被 alternate screen 换掉;
- 或者改造 agent——让它主动汇报。但 22 家 agent,只有一部分有 hook 机制。
herdr 两条都做,并且规定好了"两边打架时听谁的"。
产出是什么: 四个字。
| 状态 | 语义(源码注释) | 典型触发 |
|---|---|---|
Working | agent 正在跑 | 屏幕上有 spinner、标题栏有转圈字符 |
Blocked | agent 需要人回话,卡在等待上 | "Do you want to proceed?" 之类的确认框 |
Idle | agent 跑完了,提示符可见,没事干 | 输入框 ❯ 空着 |
Unknown | 普通 shell,或不认识的程序 | 没识别出 agent |
依据:src/detect/mod.rs:11-20(enum AgentState)。
为什么这里是四个,导读页和控制面章却说五个? 因为 Done 不是第五个内部状态,它是对外 API
层对 Idle 的一种呈现:(Idle, 这个 tab 还没被人看见过) 渲染成 Done,看见过就还是 Idle。
依据:pane_agent_status,src/app/api_helpers.rs:96-105。本章之后一律只谈上表这四个内部状态,
Done 的产生规则属于控制面。
2. 状态词表与四个布尔位
光有一个状态字还不够。同样是"我看到屏幕上写着 Idle",证据强度差别很大——是当前活着的输入框,
还是用户翻上去看到的历史记录?所以检测器返回的不是 AgentState,而是一个带证据的结构。
pub struct AgentDetection {
pub state: AgentState,
pub skip_state_update: bool,
pub visible_idle: bool,
pub visible_blocker: bool,
pub visible_working: bool,
}
依据:src/detect/mod.rs:24-39(struct AgentDetection)。
四个布尔位不是同一层的标志,它们各自代表一个权威等级:
| 位 | 白话意思 | 权威做什么用 |
|---|---|---|
skip_state_update | "这屏是个查看器,别信它" | 最高。整次更新直接丢弃,状态原地不动 |
visible_blocker | "现在屏幕上真有一个等人的表单" | 强正证据。可以顶掉非全生命周期 hook 报的非 blocked 状态 |
visible_idle | "现在屏幕上真有一个空着的输入框" | 中。可以跳过 working→idle 的防抖等待,立即发布 |
visible_working | "现在屏幕上真有 spinner" | 弱。源码注释称其为诊断元数据 |
skip_state_update 为什么排最高?因为它对应的场景是"用户按了 Ctrl+O 打开了 transcript 查看器"。
这时屏幕上全是历史对话,里面什么词都有——你按普通规则去匹配,必然误判。所以它不是"匹配到一个
新状态",而是"这一帧作废"。
manifest 校验器强制了这一点:一条规则只要写了 skip_state_update,它的 state 必须是
"unknown",并且不准同时声明任何 visible_*——否则整个 manifest 加载失败。
依据:src/detect/manifest.rs:910-923(validate_manifest)。
22 家 agent,20 张规则表
herdr 认识 22 个 agent(Agent::ALL,src/detect/mod.rs:69-92),但只有 20 个有屏幕规则表
(Agent::SCREEN_MANIFEST_AGENTS,src/detect/mod.rs:94-115)。
差的两个是 Omp 和 Mastracode。它们不需要屏幕规则,因为它们是全生命周期 hook 权威——
插件会把每一步状态直接推过来,猜屏幕纯属浪费 CPU。仓库里有一条测试专门钉住这个不变量:
mastracode_is_hook_authority_without_screen_manifest(src/detect/mod.rs:854-861)。
3. 顶层全景:三个源,一条仲裁链
先看整体怎么转。从上到下是"证据从哪来",从左到右是"权威由弱到强"。
┌──────────────────── 每个 pane 一个 tokio 检测循环 (300ms) ────────────────────┐
│ │
PTY 字节 ──► 内嵌 Ghostty VT ──► detection_text() │
「屏幕底部 rows 行,不随用户翻页」 │
│ │
├──► OSC 标题 / 进度 ──┐ │
│ │ │
└──► 屏幕文本 ──┤ │
▼ │
① 规则表匹配引擎 │
(agent 对应的 .toml) │
│ │
AgentDetection{state,4 bit} │
│ │
② 调度与抖动抑制 │
(跳扫描 / 压降级 / 只发变化) │
└─────────────────────────────────────────────────────── ───┼───────────────────┘
│
AppEvent::StateChanged │
▼
agent 里的 hook 脚本 ──socket──► AppEvent::HookStateReported ──► ③ TerminalState 仲裁
│
▼
effective state → 侧边栏 / API / 事件
怎么读这张图: ① 负责"这一屏说明什么",② 负责"要不要说出来",③ 负责"和 hook 打架时听谁的"。 三段完全解耦——检测器只读一份文本快照,不碰解析器也不碰 viewport 状态。
一个关键设计:检测读的不是用户看到的画面。detection_text() 取的是缓冲区最底部的
rows 行,用户往上滚动不影响它(ghostty_detection_text,src/pane/terminal.rs:2542)。否则用户
一翻历史,侧边栏状态就全乱了。herdr 一共有六种读屏口径(视口 / 底部、保留软换行 / 解包),完整
对照表和它们各自的实现差 异见 终端内核 §5.1——本章只用 detection_text
这一种。
4. 屏幕规则 DSL:把"认字"写成 TOML
4.1 思路:为什么不用 Rust 硬编码
agent 的 UI 一个月一变。如果每家 agent 的匹配逻辑都写死在 Rust 里,每次上游改个提示语,herdr 就得发一个新版本。所以 herdr 把匹配规则抽成 TOML 声明式规则表,一个 agent 一个文件,内置在 二进制里,同时允许本地覆盖和远程更新——改规则不用重编译,甚至不用重启。
术语对齐: 源码里这份 TOML 叫 manifest(
load_manifest、validate_manifest、MANIFEST_ENGINE_VERSION),导读页称它「检测清单」。本章统一写作「规则表」——三个名字指 同一样东西,后文不再区分。
20 张表在 src/detect/manifests/*.toml,用 include_str! 编进二进制
(src/detect/manifest.rs:239-260,BUNDLED_MANIFESTS)。