跳到主要内容

数据截至 (上游 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 两条都做,并且规定好了"两边打架时听谁的"。

产出是什么: 四个字。

状态语义(源码注释)典型触发
Workingagent 正在跑屏幕上有 spinner、标题栏有转圈字符
Blockedagent 需要人回话,卡在等待上"Do you want to proceed?" 之类的确认框
Idleagent 跑完了,提示符可见,没事干输入框 空着
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)。

差的两个是 OmpMastracode。它们不需要屏幕规则,因为它们是全生命周期 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_manifestvalidate_manifestMANIFEST_ENGINE_VERSION),导读页称它「检测清单」。本章统一写作「规则表」——三个名字指 同一样东西,后文不再区分。

20 张表在 src/detect/manifests/*.toml,用 include_str! 编进二进制 (src/detect/manifest.rs:239-260,BUNDLED_MANIFESTS)。

4.2 一条规则长什么样

先看最简单的(pi):

[[rules]]
id = "working_literal"
state = "working"
priority = 100
region = "whole_recent"
visible_working = true
contains = ["Working..."]

依据:src/detect/manifests/pi.toml:7-13。读法:在整屏文本里找 Working...,找到就判 working, 并且这算一条"看得见的 working 证据",优先级 100。

4.3 解剖样本:claude.toml 的三条规则

Claude Code 是最值得看的一张表,因为 herdr 给 claude 装的 hook 只报会话身份、完全不报状态 (见 §6),所以 claude 的状态 100% 靠屏幕规则撑起来。

样本 A — 从标题栏抓 spinner(最高优先级)

[[rules]]
id = "osc_title_working"
state = "working"
priority = 1100
region = "osc_title"
visible_working = true
# Braille covers <= 2.1.227; half-circles are the 2.1.228 busy spinner.
regex = ['^[\x{2800}-\x{28FF}\x{25D0}-\x{25D3}] ']

依据:src/detect/manifests/claude.toml:7-14

妙在哪:Claude 会把转圈动画的那个字符写进终端标题(OSC 序列),而不只是画在屏幕上。标题是一个 结构化、不会被滚动、不会被其它文本污染的通道。所以这条规则拿了全表最高优先级 1100。注释还 记下了版本考古:2.1.227 及以前用盲文点阵字符 ⠋⠙⠹…,2.1.228 换成了半圆 ◐◑◒◓——正则把两段 Unicode 区间都收了。

样本 B — 认出"这是查看器,别信"

[[rules]]
id = "transcript_viewer"
state = "unknown"
priority = 1000
region = "bottom_non_empty_lines(3)"
skip_state_update = true
contains = ["showing detailed transcript"]
any = [
{ contains = ["ctrl+o", "to toggle"] },
{ contains = ["ctrl+e", "show all"] },
{ contains = ["ctrl+e", "collapse"] },
{ contains = ["↑↓ scroll"] },
{ contains = ["? for shortcuts"] },
]

依据:src/detect/manifests/claude.toml:72-85

读法是一个 AND-OR 门:必须同时满足

  • contains 里的每一项(这里只有一项:底部三行非空文本包含 showing detailed transcript),并且
  • any至少一项成立(这五项是同一个功能在不同版本 / 不同宽度下的替代写法)。

这就是仓库里说的"哪些可见控件是不变量、哪些是备选项,显式编码成 AND/OR 门"。不变量放 contains, 备选项放 any

样本 C — 认出"活着的确认框"

[[rules]]
id = "live_blocked_form"
state = "blocked"
priority = 980
region = "after_last_horizontal_rule"
visible_blocker = true
contains = ["esc to cancel"]
any = [
{ contains = ["enter to confirm"] },
{ contains = ["enter to select"], any = [
{ contains = ["tab/arrow keys to navigate"] },
{ contains = ["arrow keys to navigate"] },
{ contains = ["arrows to navigate"] },
{ contains = ["↑/↓ to navigate"] },
{ contains = ["↑↓ to navigate"] },
] },
]

依据:src/detect/manifests/claude.toml:87-103

三个要点:

  1. region 选 after_last_horizontal_rule —— 只看"最后一条横线之后"的内容。Claude 的确认框 画在一条 ─────── 下面,历史记录里那些早就答完的旧确认框在横线之上,被这个 region 自动切掉。 这是"用布局定位活控件"而不是"全屏找关键词"。
  2. esc to cancel 是不变量,任何一个活着的表单都有它。
  3. any 可以嵌套 —— 第二个分支自己又带了一个 any,表达"既要有 enter to select,又要有某种 导航提示(五种写法任选)"。

4.4 region 选择器:先切文本,再匹配

region 决定这条规则在哪一段文本上跑。这是整个 DSL 里最关键的抗噪手段——切得准,就不用写 复杂的正则去排除历史噪声。

region切出什么实现
whole_recent整份检测快照(默认值)manifest.rs:1266
bottom_lines(n)最后 n 行(含空行)bottom_lines,manifest.rs:1319
bottom_non_empty_lines(n)从底往上数到第 n 个非空行,再取到结尾bottom_non_empty_lines,manifest.rs:1325
top_non_empty_lines(n)从顶往下数 n 个非空行top_non_empty_lines,manifest.rs:1341
after_last_horizontal_rule最后一条 ─── 之后after_last_horizontal_rule,manifest.rs:1446
prompt_box_body输入框内部(倒数第二条横线到下一条横线之间)prompt_box_body,manifest.rs:1424
above_prompt_box / last_non_empty_above_prompt_box输入框之上 / 之上最后一行非空manifest.rs:1437manifest.rs:1459
after_last_prompt_marker 等五个 codex 专用围绕 codex 的 提示符与 •■✗✓ 块标记切分manifest.rs:1357-1414
osc_title / osc_progress不读屏幕,直接取 OSC 字段region() 前两个分支,manifest.rs:1258-1262

注意 bottom_non_empty_lines(3)bottom_lines(3) 的区别:agent TUI 底部常有大量空行填充, 按物理行数截会切到空气,按"非空行数"截才稳。

几个细节值得看:

  • 横线的判定有容错:一串 后面要么什么都没有,要么这串至少 3 个字符(允许 ─── Title ─── 这种带标题的分隔线)。依据:is_horizontal_rule,src/detect/manifest.rs:1480-1499
  • "输入框"怎么定位:从底往上找第 2 条横线当作输入框顶边。依据:prompt_box_top_border_index, src/detect/manifest.rs:1467-1478
  • osc_title 是纯字段读取,不经过任何屏幕切片,所以标题规则完全免疫屏幕内容。

4.5 gate 语义:一条规则内部怎么算

规则顶层的 contains / regex / line_regex / all / any / not 会被打包成一个 ManifestGate,和嵌套 gate 用同一套语义递归求值。

字段语义大小写
contains全部子串都要出现不敏感(两边都转小写)
regex全部正则都要在整段 region 上命中敏感
line_regex每个正则都要存在某一行命中敏感
all嵌套 gate 全部成立
any非空时,嵌套 gate 至少一个成立
not嵌套 gate 一个都不能成立

依据:compiled_gate_matches,src/detect/manifest.rs:1206-1253;大小写处理见 compile_gate 把 needle 转小写(manifest.rs:1164)与 compiled_rule_matches 把 region 文本转小写 (manifest.rs:1179-1182)。

一句话记法:同级字段之间是 AND,any 内部是 OR,not 是禁止项。 所以 live_prompt_box 那条 idle 规则可以写成"输入框里有 开头的行,但不许出现 enter to select / esc to cancel / 导航提示"——这样一个长成输入框但其实是菜单的界面就不会被误判成 idle (src/detect/manifests/claude.toml:113-126)。

4.6 谁赢:priority 与 fallback

所有规则全部求值(不是命中即停),然后按 priority 选最大的一个。平票时先出现的规则赢—— 因为比较写的是 previous.priority >= rule.priority 就保持不变。 依据:evaluate_loaded_manifest,src/detect/manifest.rs:424-447

命中之后,四个布尔位还要和最终状态对得上才算数:

visible_idle: rule.visible_idle && state == AgentState::Idle,
visible_blocker: rule.visible_blocker && state == AgentState::Blocked,
visible_working: rule.visible_working && state == AgentState::Working,

依据:src/detect/manifest.rs:480-482。这防止规则表写错时产生"blocked 状态却带着 visible_idle" 这种自相矛盾的证据。

一条都没命中怎么办? 不是 Unknown,而是 Idle,并附带原因 default_known_agent_idle_fallback。依据:fallback_explain,src/detect/manifest.rs:527-542, 常量在 src/detect/manifest.rs:14

这个默认值是有取向的:既然进程是已知 agent 而屏幕上没有任何忙碌 / 阻塞证据,那"闲着"比"不知道" 对用户更有用——侧边栏至少能告诉你这个 pane 没在跑。

4.7 可解释性:explainDetectionExplain

调规则表最痛苦的事是"为什么没匹配上"。herdr 直接把匹配过程做成了一等公民 API。

explain_with_input(agent, DetectionInput{screen, osc_title, osc_progress}) 走和生产 完全相同的求值路径,但返回一个全量报告而不是压缩后的四个字。 依据:src/detect/manifest.rs:353-358

DetectionExplain 字段组内容
结果state / visible_* / skip_state_update / skipped_update_reason / fallback_reason
来源source(ManifestSource)/ manifest_version / cached_remote_version / warning
过程matched_rule(MatchedRule)+ evaluated_rules(每条规则的 RuleEvidence)
远程remote_update_status / remote_update_error / local_override_shadowing_remote

依据:src/detect/manifest.rs:28-47(DetectionExplain)、:94-99(MatchedRule)、 :112-121(RuleEvidence)。

RuleEvidence 里最实用的是 region_preview——它把这条规则实际看到的那段文本截前 240 字 带回来(bounded_preview,src/detect/manifest.rs:1197-1204)。也就是说,当一条规则没命中,你 一眼就能看出是 region 切歪了,还是关键词变了。

这套报告经 explain_to_json_value(src/detect/manifest.rs:800)序列化,由服务端 herdr agent explain <pane> --json 暴露出来(src/app/api/agents.rs:228-241)。有一个特例:当 全生命周期 hook 权威在线时,服务端直接返回 screen_detection_skipped: true + "screen_detection_skip_reason": "full_lifecycle_hook_authority",压根不跑屏幕匹配 (src/app/api/agents.rs:195-212)。

4.8 三层来源:内置 / 远程 / 本地覆盖

同一个 agent 的规则表可能有三个来源,ManifestSource 记录到底用了哪个:

pub enum ManifestSource {
Bundled,
Remote { path: PathBuf, version: String },
Override(PathBuf),
}

依据:src/detect/manifest.rs:50-54

优先级(高到低)与各自的守门规则:

① Override ~/.config/herdr/agent-detection/<agent>.toml
└─ 存在即最高;id 对不上 / 解析失败 / 编译失败 → 退回下一层,并挂 warning 字符串
② Remote <state_root>/remote/<agent>.toml (herdr.dev 目录抓下来的缓存)
└─ 版本比内置旧 → 忽略;内容变了但版本没变 → 拒绝提交
③ Bundled include_str! 进二进制的 src/detect/manifests/<agent>.toml
└─ 兜底。编译失败直接 panic —— 内置表坏了属于发版事故,不该静默降级

依据:load_manifest_uncached,src/detect/manifest.rs:568-665;覆盖路径由 override_path 拼出(src/detect/manifest.rs:1098-1104);内置表 panic 在 bundled_loaded_manifest(src/detect/manifest.rs:699-704)。

注意 降级从不静默:每次退回都往 LoadedManifest.warning 写一句人话("ignored override X because manifest id Y does not match Z"),这句话会一路传到 explain 的 JSON 里。

引擎版本握手。 远程表必须声明 min_engine_version,大于当前引擎(MANIFEST_ENGINE_VERSION = 3, src/detect/manifest_update.rs:15)就拒绝加载。这样新语法(比如 top_non_empty_lines,要求引擎 ≥ 3) 不会把老版本 herdr 打挂。依据:parse_remote_manifest_for_agent,src/detect/manifest.rs:866-892; 新 region 的版本门在 src/detect/manifest.rs:926-935

复杂度上限。 规则表来自网络,所以解析时就卡死上限:每表 128 条规则、gate 嵌套 8 层、总 gate 512、 单 gate 32 个 matcher、总 matcher 1024、单个 matcher 512 字符。 依据:src/detect/manifest.rs:265-270validate_manifest / validate_gate (src/detect/manifest.rs:894-1018)。

4.9 热重载:改规则不重启

规则表编译一次缓存在进程里(MANIFEST_CACHE,一个 OnceLock<RwLock<ManifestCache>>, src/detect/manifest.rs:262)。reload_manifests() 重建整个缓存并原子换上,外面还套了一把 MANIFEST_RELOAD_LOCK 保证同一时刻只有一个重建在跑。 依据:src/detect/manifest.rs:272-285

两个触发口:

  • 手动:herdr server reload-agent-manifests(src/cli/server.rs:15)→ API Method::ServerReloadAgentManifestsreload_manifests()(src/app/api.rs:986)。
  • 自动:后台 auto_update()https://herdr.dev/agent-detection/index.toml 目录,逐个下载新表, 只有真的有表被更新才重载,最后发一个 AppEvent::AgentDetectionManifestsUpdated。 依据:src/detect/manifest_update.rs:168-194

远程更新的落盘有三道防线,值得单独记:

防线做什么依据
版本单调远程版本比缓存旧 → 报错拒绝process_agent_manifest,manifest_update.rs:281-287
内容指纹版本相同但内容不同 → 报错("changed content without a version bump")manifest_update.rs:288-297
原子写写临时文件 + rename + fsync 父目录atomic_write,manifest_update.rs:401

第二条是防投毒的:同一个版本号必须永远对应同一份内容,否则缓存和真相就对不上了。


5. 扫描调度与抖动抑制:什么时候看、什么时候闭嘴

规则表解决"这一屏说明什么"。但一个 15 pane 的会话里,盲目地每 300ms 扫 15 屏文本 × 每屏几十条 正则,是纯粹的浪费;而且屏幕状态天然会抖——agent 两次工具调用之间会有几十毫秒的"看起来 idle"。

所以检测循环外面套了四层闸门。

每 300ms 醒一次(pending_idle 期间加密到 100ms)

├─① 全生命周期 hook 在线且进程没退? ──► 整轮跳过,屏幕完全不看

├─② agent 刚起来不到 3 秒? ──► 跳过(启动画面不作数)

├─③ 当前 idle 且 PTY 自上次扫描后没吐过字节? ──► 跳过读屏

├──► 读 detection_text + OSC ──► 规则匹配 ──► AgentDetection
│ │
│ skip_state_update? ──┴──► 丢弃,状态不动

└─④ working ──► 光秃秃的 idle? ──► 先扣住,要连续确认才放行
└──► 发布(且只在真的变了时发布)

① hook 优先 —— lifecycle_authority_active && !process_exited 直接 continue。 依据:src/pane.rs:861-864

② 启动宽限 3 秒 —— 换了新 agent 就设 agent_startup_grace_until = now + AGENT_STARTUP_GRACE_WINDOW, 窗口内不判状态。依据:src/pane.rs:837、常量 src/pane/agent_detection.rs:12-13。这是防"agent 的 欢迎横幅碰巧撞上某条规则"。

③ idle 时按内容序号跳扫描 —— 这是省 CPU 的大头。PTY 每收到一批非空字节, observe_detection_content_change 就把一个原子计数器 +1 (src/pane/agent_detection.rs:319-323)。检测循环在 idle 且计数器没变 时直接跳过读屏:

input.current_detection_content_seq.is_some()
&& input.last_screen_scan_detection_content_seq == input.current_detection_content_seq

依据:should_skip_idle_screen_scan,src/pane/agent_detection.rs:91-103;调用入口 decide_detection_screen_read,src/pane/agent_detection.rs:122-138

为什么只在 idle 时敢跳?因为 idle 是"没动静"状态,而任何状态变化的前提都是 agent 吐了新字节。 反过来,working / blocked 时即使字节没变也要复扫(比如 hook 那边刚变了、或者要刷新 blocked 信号)。 另外 pending_idle 期间、agent 刚换、进程刚退,一律强制读——见同函数前四个短路条件。

本地操作(比如程序化清屏)也能手动打这个计数器:mark_detection_content_changed (src/pane/agent_detection.rs:325-327)。

④ working→idle 的降级保护 —— 这是最容易被感知到的一条。

previous = Working,next = Idle,且 next 没有 visible_idle / visible_blocker,
agent 没换、进程没退 ──► 判定为「光秃秃的 idle」,先扣住不发

扣住的规则:第一次记时间戳并 hold;之后每次 +1 确认,攒够 3 次放行;或者超过 700ms 上限 强制放行。依据:PendingIdleConfirmation::should_hold_working_to_idle, src/pane/agent_detection.rs:39-77;常量 AGENT_PENDING_IDLE_CONFIRMATIONS = 3 (:7)、AGENT_PENDING_IDLE_CAP = 700ms(:8-9);扣住期间轮询加密到 100ms (AGENT_PENDING_IDLE_RECHECK,:5-6;调度处 src/pane.rs:717-720)。

关键是那个 !next.visible_idle 条件:如果屏幕上真的出现了空输入框,就不用等 —— 因为那是硬证据,不是"两个工具调用之间的空档"。测试 visible_idle_bypasses_plain_idle_hold 钉住了这条(src/pane/agent_detection.rs:460-469)。

发布节流。 状态算出来了也不一定发。should_publish_detection_update 只在下列情况发事件: 状态变了、任一 visible_* 变了、agent 换了、进程退了,或者"blocked 持续中且到了 800ms 刷新点" (STABLE_VISIBLE_SIGNAL_REFRESH)。依据:src/pane/agent_detection.rs:140-168

最后那条"blocked 定期重发"是给下游用的:持续阻塞状态需要周期性心跳,好让通知 / 高亮之类的 逻辑知道"这个 pane 还在等你,不是我忘了"。

两个诚实的观察:

  1. AgentDetection.visible_working 的注释写着"PTY 活动才是 working 的常规权威" (src/detect/mod.rs:35-37),但在本 commit 的发布路径里,src/pane.rs 从不自行构造 AgentState::Working——working 只能来自规则表或 hook。测试名 screen_publish_keeps_visible_working_without_pty_activity (src/pane/agent_detection.rs:495)也印证了这个方向。注释比代码旧。
  2. stabilize_agent_detection 现在是个恒等函数,直接返回 detection.state (src/terminal/state.rs:2169-2171)。它是给未来的稳定化逻辑留的接缝,当前不做任何事。

6. 更高权威:hook 说了算的时候

屏幕检测再准也是猜。如果 agent 自己愿意汇报,那当然听它的。这就是 HookAuthority

pub struct HookAuthority {
pub source: String, // 例如 "herdr:opencode"
pub agent_label: String, // 例如 "opencode"
pub state: AgentState,
pub message: Option<String>,
pub reported_at: Instant,
pub session_ref: Option<AgentSessionRef>,
}

依据:src/terminal/state.rs:18-26

6.1 三档 hook,权威完全不同

同样是 hook,herdr 按 (source, agent_label) 二元组把它们分成三档——写死在代码里,不可配置:

档位成员权威判定函数
全生命周期pi / omp / mastracode / opencode / kilo / kimi完全接管状态,屏幕检测被整轮跳过full_lifecycle_hook_authority,src/detect/mod.rs:295-305
仅身份hermes / qwen / agyset_hook_authority_at 直接返回 None,只用来认会话 ID,状态归屏幕session_identity_only_integration,src/detect/mod.rs:307-312
普通其余装了 hook 的 agent能设权威,但可被屏幕上的 visible_blocker 顶掉落在上面两个之外

第二档的拦截在 set_hook_authority_at第一行:

if crate::detect::session_identity_only_integration(&source, &agent_label) {
return None;
}

依据:src/terminal/state.rs:633-643。仓库里的测试 session_identity_integrations_leave_state_to_screen_detection(src/detect/mod.rs:863-874) 同时断言这三个 agent 都在 SCREEN_MANIFEST_AGENTS 里——即"交出状态权,必须有规则表接盘"。

6.2 仲裁的最终一行代码

所有权威最后汇到一处:

let state = if self.visible_blocker_overrides_hook() {
AgentState::Blocked
} else {
self.hook_authority
.as_ref()
.filter(|authority| self.hook_authority_is_effective(authority))
.map(|authority| authority.state)
.unwrap_or(self.fallback_state)
};

依据:recompute_effective_state,src/terminal/state.rs:2125-2141fallback_state 就是屏幕 检测那一路存进来的状态(set_detected_state_with_screen_signals_at,src/terminal/state.rs:312)。

优先级链读作:屏幕硬阻塞 > 有效 hook > 屏幕检测

6.3 守门条件:hook 什么时候不算数

第一行那个 visible_blocker_overrides_hook 有四重条件,少一个都不生效:

① 当前不是全生命周期 hook 权威 (全档 hook 不容许屏幕顶)
② fallback_visible_blocker 为真 (屏幕上确实有活着的阻塞表单)
③ fallback 的观测时刻 ≥ hook 上报时刻 (屏幕证据不比 hook 旧)
④ hook 报的不是 blocked,且 hook 的 agent 与 detected_agent 一致

依据:src/terminal/state.rs:1837-1848,其中 ① 是提前 return 的短路分支,②③④ 是后面那串 &&; ③ 用 fallback_not_older_than_hook(src/terminal/state.rs:755-760)。

hook_authority_is_effective 则解决"hook 报完之后进程换了"的问题:全生命周期权威只在 "它声称的 agent == 当前 detected_agent,且没有近期进程退出"时才算数。 依据:src/terminal/state.rs:1791-1796

hook_authority_conflicts_with_detected_agent 是配套的冲突检测:hook 说自己是 claude, 但进程检测看到的是 codex → 冲突。冲突时 hook 权威会被清掉,同时把里面的 session_ref 抢救成 persisted_agent_session,好让后面还能恢复会话。 依据:src/terminal/state.rs:762-772(判定)与 :551-570(清理与抢救)。

序号去陈。 每个 source 记一个 seq,只接受严格递增的上报,并且限制最多 32 个 source 以防内存被撑爆。依据:sequence_is_fresh / accept_sequence / MAX_SEQUENCE_SOURCES, src/metadata_tokens.rs:15-39。hook 脚本用 time.time_ns() 当 seq(见 src/integration/assets/claude/herdr-agent-state.sh:61),乱序到达的旧包会被直接丢掉。

6.4 展示层元数据:另一条独立通道

hook 除了报状态,还能报纯展示信息——标题、显示名、每个状态的自定义文案。这条通道和状态权威 完全分开,走 AgentMetadataReportAgentMetadataEffectivePresentation。 依据:src/terminal/metadata.rs:9-52

机制作用依据
TTLreported_at + ttl 到期后这条元数据不再参与展示agent_metadata_is_expired,metadata.rs:498-508
守卫 agent_label只有当它等于当前 effective agent 才生效metadata_guards_match,metadata.rs:126-137
守卫 applies_to_source只有当它等于当前 hook 权威的 source 才生效同上
seq 去陈旧序号的上报直接丢弃accept_metadata_report,metadata.rs:94-124
取新不取旧多个 source 各报一份时,按 reported_at 取最新newest_metadata_title 等,metadata.rs:418-433

两个守卫是关键:它们保证"上一个 agent 留下的标题"不会挂在下一个 agent 头上。TTL 则保证 hook 脚本崩了之后,陈旧展示会自己消失而不是永远钉在侧边栏。


7. hook 怎么被装进 agent

hook 不会自己出现——herdr 要把脚本写进 agent 的配置目录,还要把调用它的配置项塞进 agent 的 配置文件。这一步叫 integration install。

7.1 资产与版本

每个 agent 的 hook 脚本用 include_str! 编进 herdr 二进制,按平台选 .sh / .ps1:

const CLAUDE_HOOK_ASSET: &str = if cfg!(windows) {
include_str!("assets/claude/herdr-agent-state.ps1")
} else {
include_str!("assets/claude/herdr-agent-state.sh")
};
const CLAUDE_INTEGRATION_VERSION: u32 = 8;

依据:src/integration/mod.rs:36-41;codex 同构在 :47-52

版本号同时写在常量脚本文件的注释头里,靠一个魔法字符串对上: INTEGRATION_VERSION_MARKER = "HERDR_INTEGRATION_VERSION="(src/integration/mod.rs:257), 脚本里对应 # HERDR_INTEGRATION_VERSION=8 (src/integration/assets/claude/herdr-agent-state.sh:6)。herdr 靠读这一行判断磁盘上的脚本是不是过期了。

7.2 装到哪、怎么装

统一入口 install_target(target)(src/integration/actions.rs:16),按 agent 分派。以 claude 为例:

~/.claude/hooks/herdr-agent-state.sh ← 写脚本 + chmod +x
~/.claude/settings.json ← 编辑配置,挂上 SessionStart 钩子

配置编辑不是"读 JSON、改、整份写回"——那会把用户的注释和格式全毁掉。herdr 用 jsonc_parserCST(具体语法树)最小侵入编辑,只动要动的那几个节点。而且编辑完还会把结果重新解析一遍, 和期望的 serde_json::Value 比对,不一致就报错(verify_updated, src/integration/claude_settings.rs:522)。

同一个模块还负责清理自己的历史包袱:HOOK_REMOVALS 列出了 9 个 herdr 曾经装过、现在不再用的 钩子事件(PostToolUse/UserPromptSubmit/Stop/SessionEnd…),安装时逐个摘掉。 依据:src/integration/claude_settings.rs:22-58,安装流程 :61-91

opencode 走的是另一条路——不是挂钩子,而是往 tui.jsoncplugin 数组里追加一个插件路径, 同样用 CST 追加而不是重写文件。依据:add_tui_plugin,src/integration/opencode_config.rs:33-68

7.3 claude 的 hook 现在只做一件事

这是本章最有信息量的一处。看脚本主体:

case "$action" in
session) ;;
*) exit 0 ;;
esac

依据:src/integration/assets/claude/herdr-agent-state.sh:15-18只接受 session 这一个 action, 其余一律退出。 后面 Python 段发出的请求也只有一个方法:

request = {
"id": request_id,
"method": "pane.report_agent_session",
"params": params,
}

依据:src/integration/assets/claude/herdr-agent-state.sh:81-85,source 固定为 "herdr:claude" (:32)。

结论:herdr 对 Claude Code 只用 hook 认"这个 pane 里是哪个 session",状态一个字都不报。 配上 §6.1 的分档表(herdr:claude 不在全生命周期名单里),这解释了为什么 claude.toml 是 20 张表 里规则最多、层次最密的一张——它是 Claude 状态的唯一来源。

脚本还有两个细节值得学:

  • 子 agent 过滤:hook_input.get("agent_id") 非空说明这是 subagent 事件,直接退出,避免子任务 的生命周期污染主 pane(:52-54)。
  • SubagentStop 显式拉黑,注释解释了原因:Claude 的 recap / away-summary 可能在主回合结束之后 才发 SubagentStop,老版本 herdr 把它映射成 working,结果一个已经 idle 的 pane 被"复活" (:55-59)。

8. 事件出口:检测结果怎么流出去

检测循环不直接改状态,它发事件。所有出口都在 AppEvent(src/events.rs:56-174):

事件谁发落到哪
AgentProcessDetected进程探测认出了 agent(状态还没确认)set_detected_agent_process_at,src/app/actions.rs:2776-2785
StateChanged屏幕检测发布路径set_detected_state_with_screen_signals_at,src/app/actions.rs:2786-2806
HookStateReportedsocket 上的 pane.report_agentset_hook_authority_with_session_ref,src/app/actions.rs:2808-2836
AgentSessionReportedsocket 上的 pane.report_agent_sessionset_agent_session_ref_for_session_start,src/app/actions.rs:2838-2854
HookMetadataReportedsocket 上的 pane.report_metadataset_agent_metadata,src/app/actions.rs:2859+
AgentDetectionManifestsUpdated后台远程规则表更新完成刷新 UI 里的 manifest 摘要

注意 AgentProcessDetectedStateChanged两个事件而不是一个:进程识别(ps 层面看到 claude 在跑)比屏幕状态确认早得多,先把 agent 身份亮出来,侧边栏就能立刻显示图标,状态随后补上。

socket 端点在 handle_pane_report_agent(src/app/api/panes.rs:1231-1258)和 handle_pane_report_agent_session(:1260-1287)。两者都会先做 normalize_reported_agent_label, 把 claude-code 之类的别名归一到规范 label——别名表在 lookup_agent (src/detect/mod.rs:188-214)。


9. 巧妙之处(可以偷走的技术)

  1. 用终端标题当带外信道。 OSC 标题是一个不会被滚动、不会被别的文本污染的结构化字段。 claude 的最高优先级规则就架在上面(src/detect/manifests/claude.toml:7-14)。任何要"从终端 UI 里读状态"的工具都该先看看有没有 OSC 可用。

  2. 不变量 / 备选项分离。 contains 放"任何版本都有的那个词",any 放"同一功能的各种写法"。 这让规则对上游文案改动有弹性,又不会宽到误伤——见 transcript_viewer (src/detect/manifests/claude.toml:72-85)。

  3. 先切文本再匹配。 after_last_horizontal_rule / prompt_box_body 这类 region 用布局把 "活控件"从"历史噪声"里切出来,省掉了一大堆排除性正则(src/detect/manifest.rs:1424-1457)。

  4. "这一帧作废"是独立于状态的一档。 skip_state_update 不是第五种状态,而是一个否决权,并且 被校验器强制和 state = "unknown" 绑定(src/detect/manifest.rs:910-923)。

  5. 省 CPU 靠"没有新字节就不看"。 一个原子计数器把 PTY 活动和扫描调度接起来,idle pane 的 检测开销降到接近零(src/pane/agent_detection.rs:91-103 + :319-323)。

  6. 降级要确认,升级不用。 working→idle 需要连续 3 次确认(或 700ms 超时),而 idle→blocked / working 立即发布。因为"误报忙"只是让你多看一眼,"误报闲"会让你以为可以走开了 (src/pane/agent_detection.rs:39-77)。

  7. 匹配过程即 API。 explain 走生产同一条路径,连每条规则实际看到的文本片段都带回来 (RuleEvidence.region_preview,src/detect/manifest.rs:1197-1204)。规则表调试从"加 print 重编译"变成"跑一条 CLI"。

  8. 降级从不静默。 覆盖表坏了、远程表旧了、内容和版本对不上——每一种都写一句人话进 warning 并传到 explain 输出里(src/detect/manifest.rs:602-664)。

  9. 配置编辑用 CST 而不是重写。 用户 settings.json 里的注释、缩进、键序全部保留,改完还要 重新解析比对(src/integration/claude_settings.rs:522-531)。


10. 边界与局限

  • 本质上是启发式。 上游 agent 改一句提示语,规则就可能失效。herdr 的对策不是"做得更准",而是 "做得更好改"——远程规则表 + 本地覆盖 + 热重载,让修复不必等发版。

  • 规则表不是通用状态机。 没有跨帧记忆、没有时序条件。每次求值只看当前这一份文本快照, 历史全靠外层的 PendingIdleConfirmation 兜。

  • 规则表不能声明权威等级。 一个 agent 是不是"全生命周期 hook 权威",硬编码在 src/detect/mod.rs:295-312 的两个 matches! 里,manifest 改不了。这是安全取舍:装 hook 的门槛 比改一个 TOML 高得多。

  • 没规则表就只有两种下场。 不在 SCREEN_MANIFEST_AGENTS 里的 agent(OmpMastracode) 必须靠 hook,否则永远停在 Unknown(detect_with_oscload_manifest 返回 None 分支, src/detect/manifest.rs:336-339)。

  • 默认 idle 会把"没规则命中"和"真的闲着"混在一起。 两者都产生 Idle,只在 fallback_reason 字段上有区别(src/detect/manifest.rs:542),普通 UI 看不出来。

  • contains 大小写不敏感、regex 敏感。 同一张表里两种语义,写规则时容易踩 (src/detect/manifest.rs:1164 vs :1215)。

  • 注释与代码有漂移。 visible_working 的注释仍在描述一条"PTY 活动 = working 权威"的路径, 而当前发布路径已经不构造 Working(§5 末)。


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

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

主题文件路径关键符号
状态词表 / 证据位 / agent 名单src/detect/mod.rsAgentStateAgentDetectionAgent::ALLAgent::SCREEN_MANIFEST_AGENTS
检测入口(带 OSC)src/detect/mod.rsdetect_agent_with_oscshould_skip_state_update
hook 权威分档src/detect/mod.rsfull_lifecycle_hook_authoritysession_identity_only_integration
进程 → agent 识别(含各种 wrapper)src/detect/mod.rsidentify_agent_in_jobnormalized_process_namelookup_agent
规则求值引擎src/detect/manifest.rsdetect_with_oscevaluate_loaded_manifestcompiled_gate_matches
可解释输出src/detect/manifest.rsexplain_with_inputDetectionExplainMatchedRuleRuleEvidenceexplain_to_json_value
region 选择器src/detect/manifest.rsregionbottom_non_empty_linesafter_last_horizontal_ruleprompt_box_bodyis_horizontal_rule
三层来源与缓存src/detect/manifest.rsManifestSourceload_manifest_uncachedoverride_pathreload_manifestsBUNDLED_MANIFESTS
manifest 校验与限额src/detect/manifest.rsvalidate_manifestvalidate_gateMAX_RULES_PER_MANIFESTMAX_GATE_DEPTH
远程更新src/detect/manifest_update.rsauto_updatecheck_and_updateprocess_agent_manifestremote_manifest_pathMANIFEST_ENGINE_VERSION
规则表样本src/detect/manifests/claude.tomlosc_title_workingtranscript_viewerlive_blocked_formlive_prompt_box
扫描调度src/pane/agent_detection.rsdecide_detection_screen_readshould_skip_idle_screen_scanobserve_detection_content_change
抖动抑制与发布src/pane/agent_detection.rsPendingIdleConfirmation::should_hold_working_to_idleshould_publish_detection_updatedecide_screen_detection_publishdetection_update_for_publish_with_osc
检测循环本体src/pane.rs检测 tokio::spawn 循环(约 694-975)
检测文本来源src/pane/terminal.rsghostty_detection_textdetection_text
状态仲裁src/terminal/state.rsHookAuthorityset_hook_authority_atrecompute_effective_statevisible_blocker_overrides_hookhook_authority_is_effectivehook_authority_conflicts_with_detected_agent
屏幕状态入口src/terminal/state.rsset_detected_state_with_screen_signals_atstabilize_agent_detection
对外状态映射(IdleDone)src/app/api_helpers.rspane_agent_status
展示元数据与 TTLsrc/terminal/metadata.rsAgentMetadataAgentMetadataReportEffectivePresentationagent_metadata_is_expiredmetadata_guards_match
seq 去陈src/metadata_tokens.rssequence_is_freshaccept_sequenceMAX_SEQUENCE_SOURCES
集成资产与版本src/integration/mod.rsCLAUDE_HOOK_ASSETCLAUDE_INTEGRATION_VERSIONCODEX_HOOK_ASSETINTEGRATION_VERSION_MARKER
安装分派src/integration/actions.rsinstall_targetuninstall_target
claude 配置编辑src/integration/claude_settings.rsinstalluninstallHOOK_REMOVALSverify_updated
opencode 配置编辑src/integration/opencode_config.rsadd_tui_pluginremove_tui_plugin
claude hook 脚本src/integration/assets/claude/herdr-agent-state.shpane.report_agent_sessionHERDR_INTEGRATION_VERSION=8
事件出口src/events.rsAppEvent::StateChangedHookStateReportedAgentProcessDetectedAgentSessionReported
事件落地src/app/actions.rshandle_app_event(约 2779-2870)
socket 上报端点src/app/api/panes.rshandle_pane_report_agenthandle_pane_report_agent_session
explain APIsrc/app/api/agents.rsagent explain 处理(约 195-241)

接着读: 本章产出的 AgentState 怎么变成 agent.wait --until blocked 这类可编程原语,见 控制面;检测文本从哪来、还有哪几种读屏口径,见 终端内核 §5.1。