08 · Agent 识别与状态仲裁¶
Herdr 要回答两个不同问题:这个终端里运行的是哪个 Agent?它现在在工作、等待用户,还是空闲? 前者主要依赖进程身份,后者需要综合屏幕、OSC 和官方 integration hook。把它们混成一条正则,会在 wrapper、全屏重绘和迟到事件下迅速失效。
8.1 原始状态模型¶
AgentState 只有四个原始值:
| 状态 | 含义 | 典型证据 |
|---|---|---|
Idle |
可接收新任务/没有活跃工作 | prompt、idle OSC、hook stop |
Working |
正在推理、执行或输出 | spinner、working OSC、hook start |
Blocked |
等待用户授权/选择/输入 | permission form、Action Required、hook blocked |
Unknown |
证据不足或界面不适合更新 | transcript/menu/未知 Agent |
UI 中的 Done 不是第五个 raw state。它由 TerminalState 已回到 Idle,而关联 PaneState.seen == false 推导,表示“工作结束且用户还没看”。这避免把视图未读信息写回 Agent 生命周期。
8.2 四类证据¶
flowchart TB
P["进程组 / 进程树"] --> I["Agent identity"]
S["VT 底部近期内容"] --> M["manifest rule engine"]
O["OSC title / progress"] --> M
H["official integration hook"] --> A["TerminalState arbiter"]
I --> A
M --> A
X["sequence / generation / recency"] --> A
A --> E["effective AgentState"]
E --> UI["badge / API / events"]
| 证据 | 优势 | 弱点 |
|---|---|---|
| 进程树 | 不依赖 UI 文案,适合识别 Agent | wrapper 多、进程名可泛化、难判断状态 |
| 屏幕文本 | 能看见真实权限提示 | 文案/语言变化、transcript 残留、viewport 干扰 |
| OSC | 结构化、低成本、比 cell 文本稳定 | 不是所有 Agent 都发,可能残留 |
| integration hook | 最接近 Agent 内部生命周期 | 需安装、报告可能迟到、不同集成完整度不同 |
架构重点不是选出“唯一最好来源”,而是明确谁在什么条件下有权覆盖谁。
8.3 Agent 身份识别¶
Agent 在固定快照中有 21 个变体。识别入口会查看终端前台 process group/job,并沿进程树处理常见包装:
- shell 启动脚本;
node/bun;- Python entrypoint;
cmd.exe/ PowerShell;- package manager 或 shim;
- Agent 自己派生的工具子进程。
identify_agent_in_job 的核心意图是识别“这组前台工作属于哪个 Agent”,而不是只比较最末进程 basename。Agent 执行 cargo 时,前台叶子可能是编译器,但终端仍属于该 Agent 会话。
8.4 检测文本不等于用户视口¶
屏幕规则读取 VT 底部的近期内容,而不是用户当前 scroll viewport。原因可用一个场景说明:
- Claude 曾显示权限提示;
- 用户回答后 Agent 继续工作;
- 用户向上滚回旧提示;
- 若 detector 读 viewport,会重新判为
Blocked。
检测源应跟随 buffer 的最新底部,并用专门区域函数聚焦当前 prompt/form。用户浏览历史只影响呈现,不影响 Agent 真相。
8.5 Manifest 规则引擎¶
19 个 Agent 的 screen detection 规则放在 src/detect/manifests/,由 src/detect/manifest.rs 解析、编译和求值。
规则形状包括:
[[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"] }
]
这是对 Claude manifest 中规则结构的短摘;完整实现以固定源码为准。
Rule 字段¶
| 字段 | 作用 |
|---|---|
id |
可解释、可测试的稳定规则名 |
state |
匹配时的 raw state |
priority |
多规则同时命中时排序 |
region |
从输入中先裁出相关区域 |
all / any / not |
组合 gate |
contains |
字面包含条件 |
regex |
区域正则 |
line_regex |
逐行正则,降低跨行误判 |
visible_* |
标记证据来自当前可见强信号 |
skip_state_update |
识别到特殊 UI,但不改已有状态 |
解析使用 deny_unknown_fields,能把拼错字段变成加载错误,而不是静默失效。
8.6 Region 是准确率的关键¶
规则并不总扫整个 history。常见 region 包括:
whole_recent;bottom_non_empty_lines(n);after_last_horizontal_rule;after_last_prompt_marker;prompt_box_body;osc_title;osc_progress。
Region 先把“可能仍活跃的 UI 部分”切出来,再跑条件。这样可以区分:
- 对话历史里出现“Do you want to proceed?”;
- 当前底部真的有可选 Yes/No form。
规则越靠近当前交互区域,越适合设置 visible_blocker 这种强证据标志。
8.7 Priority 与负条件¶
同一屏幕可能同时含有工作 spinner、旧 permission 文本和当前 transcript menu。优先级让更具体的当前 UI 赢过泛化 fallback。
以 Codex 为例:
- OSC title
Action Required以高优先级判 Blocked; - OSC spinner 以略低优先级判 Working;
- transcript viewer 命中后
skip_state_update; - 当前强 blocker 再判 Blocked;
- 屏幕 working fallback;
- 普通非 spinner OSC title 最后作为 Idle 证据。
not 条件同样重要。一个 idle prompt 规则通常排除“enter to select”“esc to cancel”等 form 文本,防止光看到 prompt glyph 就覆盖 blocker。
8.8 skip_state_update 为什么不是 Unknown¶
transcript viewer、model picker 等界面往往不能从静态文本判断 Agent 生命周期。若直接返回 Unknown 并覆盖现有 Working/Blocked,UI 会因为用户打开辅助界面而抖动。
skip_state_update 表达更精确的语义:
“我确认看见了一个不应参与状态判断的界面,因此保留之前的有效状态。”
manifest 校验要求使用它的规则 state 为 unknown,且不能同时声明 visible idle/blocker/working,防止规则自相矛盾。
8.9 可解释输出¶
规则引擎不仅返回 state,还能产生 DetectionExplain/matched rule/evidence:
- 使用了哪个 manifest/source/version;
- 哪条 rule 命中;
- region 内容和 gate 结果;
- priority;
- visible flags;
- 是否 skip update。
这对维护检测规则极其重要。没有 explain,误判只能靠用户截图猜;有 explain,测试和 bug report 可以直接定位具体规则与证据。
8.10 Hook 来源的完整度不同¶
官方 integration 会主动报告 session 和状态,但并非所有来源都提供完整 lifecycle。固定快照的仲裁逻辑区分:
- full-lifecycle authority:例如 Herdr Pi、OMP、MastraCode、OpenCode、Kilo、Kimi 等来源,在对应进程存活时对状态有较强权威;
- session-only/partial:例如某些 Hermes、Antigravity 路径主要提供 session 身份,状态仍交给 screen detection。
“装了 integration”不自动等于“永远信 hook”。权威取决于 source capability、当前 Agent identity、会话是否仍活跃和报告是否新鲜。
8.11 TerminalState 的仲裁层¶
状态合并主要落在大型 TerminalState。可把它理解为保存:
screen fallback state + visible evidence timestamps
hook-reported state + source + sequence + session generation
detected agent + process liveness
effective state + revision
recompute_effective_state 的决策可概括为:
flowchart TD
Start["recompute"] --> VB{"近期 visible blocker 可覆盖?"}
VB -->|是| Blocked["Blocked"]
VB -->|否| Hook{"存在有效 hook authority?"}
Hook -->|是| HookState["采用 hook state"]
Hook -->|否| Fallback["采用 screen/process fallback"]
Blocked --> Rev["若变化则 bump revision + event"]
HookState --> Rev
Fallback --> Rev
但“visible blocker 优先”有边界:它可以纠正非完整 lifecycle hook 漏报的当前权限框;对完整 lifecycle authority 则不能随意用屏幕残留覆盖。源码用 source capability 与 recency 条件细化这一点。
8.12 Sequence 拒绝迟到报告¶
假设 hook 快速报告:
线程/IPC 调度可能让 seq 42 最后到达。若只按到达时间写 state,Agent 会错误停在 Blocked。保存 last sequence 后,只接受更大的同源报告即可拒绝旧事件。
注意 sequence 只能在相同 source/session 作用域比较;不同 Agent session 的 counter 可能重置。
8.13 Session generation 防止“进程复活”¶
更危险的竞态是旧进程退出后,迟到 hook 到达:
sequenceDiagram
participant A1 as Agent generation 7
participant T as TerminalState
participant A2 as New shell / generation 8
A1->>T: Working(seq 10, gen 7)
A1--xT: process exits
T->>T: advance/suppress generation 7
A2->>T: Idle/process identity gen 8
A1-->>T: late Blocked(seq 11, gen 7)
T-->>A1: reject stale generation
只比较 seq 不够,因为旧 generation 的 11 可能大于新会话初始 seq。身份 + generation 是正确的复合新鲜度证明。
8.14 Visible evidence 的时效¶
visible_blocker 表示当前检测区域明确存在等待用户的 UI,但 VT 历史不会自动忘记所有文本。仲裁会结合更新时间/content sequence/recency,让强屏幕证据在短窗口内有效;新输出或界面变化后,应重新求值,而不是永久锁住 Blocked。
visible_idle 和 visible_working 也帮助区分“规则猜测”与“当前 UI 强证据”,用于调节 hook/fallback 的覆盖关系。
8.15 Manifest 更新与来源优先级¶
检测规则需要比二进制发布更快迭代,因为 Agent UI 文案会频繁变化。Herdr 支持 bundled manifest 与更新/override 来源,并记录 manifest version/source。
安全要求包括:
- engine version 不兼容时拒绝;
- 解析/正则编译失败不替换当前可用规则;
- source precedence 明确;
- reload 原子切换已编译 cache;
- explain 输出显示实际使用版本;
- 测试 fixture 覆盖更新前后的典型屏幕。
这把易变的启发式从 Rust 控制流中抽出来,同时仍用严格 schema 和测试限制风险。
8.16 从 raw state 到 UI 和 API¶
有效状态改变后会影响:
- sidebar/pane badge;
- workspace 聚合状态;
agent.list/get/waitAPI;- EventHub 的 Agent 事件;
- 系统通知或声音;
- pane
seen与 Done 投影; - 自动化 prompt/wait 完成条件。
因此状态变化应只在仲裁结果真正不同且 revision 推进时发布,不能每次 screen poll 都制造重复事件。
8.17 测试策略¶
Agent 检测适合四层测试:
- 规则单测:给 screen/OSC fixture,断言 matched rule/state/flags;
- manifest lint:未知字段、重复 id、错误 regex、矛盾 flags、版本约束;
- 仲裁状态机:hook/screen/sequence/generation 的排列组合;
- 进程集成:wrapper/process group/exit 与实际 PTY 生命周期。
最重要的测试不是“某字符串等于 Working”,而是反例:旧 transcript 不应覆盖当前状态、迟到旧 generation 不应复活、full-lifecycle hook 不应被弱 screen fallback 推翻。
8.18 设计取舍¶
优点¶
- 易变 UI 规则数据化,能独立版本和解释;
- 进程身份与状态证据分离;
- 权威、sequence、generation 明确处理时间竞态;
skip_state_update避免特殊界面制造状态抖动;- Done 留在呈现层,领域语义干净。
代价¶
TerminalState的仲裁逻辑体量大,新增 source 要理解全局 precedence;- 屏幕启发式仍会随上游 UI 漂移;
- process tree 在三平台上的可见性/命名不同;
- 多种时间窗口和 flags 增加测试矩阵。
可迁移原则¶
对无法从单一权威获得的状态,不要假装有一个完美 detector;应把来源、强度、新鲜度和会话身份作为一等数据,再显式仲裁。
下一章处理另一个时间问题:服务器重启或升级后,如何恢复结构,如何尽量原生恢复 Agent,以及 Unix Live Handoff 如何让同一批 PTY 进程跨 server 可执行文件继续存活。