数据截至 (上游 commit 3667151744e3)
控制面:让 agent 用 CLI 和 socket 指挥另一个 agent
30 秒导读: herdr 的服务端把整个会话(工作区/tab/pane/agent)暴露成一个 单行 JSON 的本地 socket 协议,外加一层
herdrCLI 外壳。于是「一个 agent 用 shell 命令去开、去喂、去等另一个 agent」变成了几行 bash。这一章讲的就是这个可编程面:方法有哪些、一次调用怎么落地、以及最关键的——怎么把「等 agent 干完」做成一个会返回的函数调用。
本章是 herdr 系列的第 4 章。前置阅读建议:进程模型(谁在跑这个 socket)、agent 检测(idle/working/blocked 这些状态是怎么来的)。
1. 这是什么(零基础也能懂)
一句话定义: 控制面 = herdr 服务端对外开的一个本地 socket JSON 接口,加上把它包成人类/agent 可用命令的 herdr CLI。
它解决谁的什么问题。 假设你在终端里跑着一个 Claude Code,你想让它「顺手再开一个 Codex 去审我的 diff,等它审完再把结论拿回来」。没有控制面时,这件事做不了——子进程里再起一个 TUI agent,输出是 ANSI 乱码,你也无法知道它「是在思考还是在等你按 y」。
herdr 的答案是:别让 agent 去 fork 另一个 agent,让它去指挥服务端。服务端本来就管着一堆 PTY pane,也本来就在做 agent 状态检测,那就把这些能力开成接口。
它能做什么:
- 建/关工作区、tab、pane,调布局(
workspace.*/tab.*/pane.*)。 - 在某个空闲 shell pane 里启动一个受支持的 agent,并等它真正可交互(
agent.start)。 - 给 agent 投喂 prompt、发按键、读它的屏幕(
agent.prompt/agent.send_keys/agent.read)。 - 阻塞等待:等某段输出出现、等 agent 回到空闲、等某个事件(
pane.wait_for_output/agent.wait/events.wait)。 - 扩展面:git worktree 开分支工作区、插件跑外部命令(
worktree.*/plugin.*)。
用起来什么样。 这是 herdr 自带的 agent 契约文件里给出的典型序列(skills/herdr/SKILL.md:96-136),就是几条命令:
# 1. 在当前 pane 右边劈一个新 pane,不抢焦点
herdr pane split --current --direction right --cwd "$PWD" --no-focus
# 2. 在那个 pane 里起一个叫 reviewer 的 codex,返回时它已经能接收输入了
herdr agent start reviewer --kind codex --pane w1:p2
# 3. 投喂 prompt 并且【阻塞等到它干完】
herdr agent prompt reviewer "Review the current diff." --wait --timeout 120000
# 4. 把结果读回来
herdr agent read reviewer --source recent-unwrapped --lines 120
一句话直觉: 把它当成 终端会话版的 Docker CLI。docker run 之于容器,herdr agent start 之于 agent;区别是 herdr 还额外提供了 docker 没有的东西——"等这个进程从忙变闲" 这个原语。
2. 顶层全景(一次调用怎么走完)
怎么读这张图:从左到右是一次请求的完整生命周期;关键在最右边那个单线程事件循环——所有状态变更最终都串行地发生在那里。
调用方 API 服务端(每连接一线程) App 事件循环(单线程)
┌──────────────┐ 1行JSON ┌──────────────────────────┐ channel ┌────────────────────┐
│ herdr CLI │─────────▶│ ① 读首行请求(5s/1MiB上限)│─────────▶│ handle_api_request │
│ 或任意进程 │ socket │ ② 分流:普通 / 等待 / 流 │ │ → 改 AppState │
│ (直连socket) │◀─────────│ ③ 阻塞原语在这层自己轮询 │◀─────────│ → 返回 JSON 字符串 │
└──────────────┘ 1行JSON └──────────────────────────┘ channel └────────────────────┘
│ │
│ 读事件 │ 推事件
└────────▶ EventHub(512条环形)◀───┘
部件与职责:
| 部件 | 干什么 | 在哪个文件 |
|---|---|---|
Method 枚举 | 92 个线上方法名与参数体的唯一真源 | src/api/schema.rs:45 |
start_server_with_capabilities | 绑 socket、0600 权限、每连接开一线程 | src/api/server.rs:67 |
dispatch_to_app | 把请求塞进 channel、同步等 App 回一个字符串 | src/api/server.rs:817 |
handle_api_request | 在 App 线程里把请求打到 AppState | src/app/api.rs:925 |
wait.rs 三个原语 | 在服务端连接线程里做长轮询/事件驱动的等待 | src/api/wait.rs:22,132,177 |
EventHub | 有序号的环形事件缓冲,供订阅与等待复用 | src/api/event_hub.rs:2 |
herdr CLI | 参数解析 + 协议握手 + 退出码协议 | src/cli.rs:95 |
主线走一遍(高层): 客户端连上 socket → 写一行 JSON 请求 → 服务端解析成 Request → 若是普通方法就丢给 App 线程并同步等回应 → 写回一行 JSON → 关连接。一条连接只服务一个请求;流式方法(events.subscribe、pane.graphics.stream)和阻塞方法是这个规则的例外,它们把连接一直占着。
3. 方法表:控制面到底有多大
先看规模。 Method 枚举里有 92 个带 serde(rename) 的线上方法名,另有 4 个 serde(skip) 的内部变体(图形流的分步指令,不进线协议)。按命名空间分:
| 命名空间 | 方法数 | 干什么 | 参数体所在 |
|---|---|---|---|
pane.* | 31 | pane 增删改查、输入、读屏、图形、上报 | src/api/schema/panes.rs |
agent.* | 12 | agent 生命周期、投喂、等待、视图 | src/api/schema/agents.rs |
plugin.* | 11 | 插件登记、动作、日志、插件 pane | src/api/schema/plugins.rs |
workspace.* | 9 | 工作区 | src/api/schema/workspaces.rs |
tab.* | 7 | tab | src/api/schema/tabs.rs |
server.* | 5 | 停机、热交接、重载配置/规则表 | src/api/schema/server.rs |
worktree.* | 4 | git worktree 列/建/开/删 | src/api/schema/worktrees.rs |
layout.* | 3 | 布局导出/应用/分割比 | src/api/schema/panes.rs |
events.* | 2 | 订阅流、等一个事件 | src/api/schema/events.rs |
| 其余 | 8 | ping / session.snapshot / notification.show / client.window_title.* / popup.close / integration.* | 各自文件 |
线格式。 请求是 {"id": ..., "method": ..., "params": ...}——Method 用 #[serde(tag = "method", content = "params")] 做内部标签(src/api/schema.rs:41)。响应是 SuccessResponse { id, result } 或 ErrorResponse { id, error: { code, message } }(src/api/schema/response.rs:24-39),result 自己再用 type 字段做标签。
一次真实往返长这样:
{"id":"cli:agent:get","method":"agent.get","params":{"target":"reviewer"}}
{"id":"cli:agent:get","result":{"type":"agent_info","agent":{"agent_status":"idle", ...}}}
schema 是生成的,不是手写的。 所有参数体都 derive(schemars::JsonSchema),protocol_schema_document() 把 5 个顶层入口(request / success_response / error_response / event / subscription_event)打成一个 250KB 的 bundle(src/api/schema/tests.rs:32-46)。这个 bundle 被 提交进仓库 且被测试盯着——generated_protocol_schema_artifact_is_current 会比对 docs/next/api/herdr-api.schema.json,不一致直接测试失败,要用 HERDR_UPDATE_API_SCHEMA=1 重新生成(src/api/schema/tests.rs:153-178)。
它还被 include_str! 编 进二进制(src/cli/api.rs:1),所以 herdr api schema --json 是离线的:不需要连服务端就能把完整 schema 吐出来给一个 agent 读。摘要模式刻意做得很小(测试断言 < 400 字符,src/cli/api.rs:112-117),就是为了不炸 agent 的上下文窗口。
4. 请求通路:从 socket 字节到 AppState
这节讲一次调用在服务端内部的四道关卡。
4.1 关卡一:接连接
start_server_inner 绑好 socket、把权限收紧到 0600,然后为每一个进来的连接 spawn 一个 OS 线程(src/api/server.rs:82-127)。这是刻意的:阻塞原语要在连接线程里长期驻留,用线程比用 async 任务更简单。
首行读取有两道保护:5 秒读超时(INITIAL_REQUEST_TIMEOUT)和 1MiB 行长上限(MAX_INITIAL_REQUEST_BYTES),都在 src/api/server.rs:30-32。
4.2 关卡二:分流
handle_connection_with_stop 按方法把请求分成四类走不同路径(src/api/server.rs:204-308):
| 类别 | 方法 | 连接行为 |
|---|---|---|
| 流式图形 | pane.graphics.stream | 长驻,持续写帧 |
| 订阅流 | events.subscribe | 长驻,每 100ms 轮询各订阅并推送 |
| 阻塞等待 | events.wait / agent.prompt / agent.wait / pane.wait_for_output | 长驻,直到匹配/超时/客户端断开 |
| 其余 88 个 | —— | 单次请求-响应后关闭 |
注意 agent.prompt 在这里被截胡了:即使没带 wait,它也先进 prompt_agent,由后者判断要不要退化成一次普通 dispatch(src/api/wait.rs:185-193)。
4.3 关卡三:跨线程投递
dispatch_to_app 是唯一的过河点:构造一个 ApiRequestMessage(带一个 std::sync::mpsc 回信通道)发进 ApiRequestSender,然后同步等回信(src/api/server.rs:817-875)。
这里有个容易读漏的分寸:超时是可选的。
| 调用场景 | 超时 | 后果 |
|---|---|---|
普通外部请求(handle_request → dispatch_to_app(..., None, ...)) | 无 | App 线程卡住 = 客户端一起卡住 |
阻塞原语内部的探针(agent.get / pane.read) | APP_RESPONSE_TIMEOUT = 5s | 探针超时会变成 server_unavailable,而不是把整个 wait 拖死 |
APP_RESPONSE_TIMEOUT 定义在 src/api/server.rs:29。轮询节拍 CONNECTION_POLL_INTERVAL = 100ms(src/api/server.rs:28),下面所有 wait 循环都用它。
4.4 关卡四:落到 AppState,以及「要不要重画」
App 线程收到消息后走 handle_api_request_message(src/app/runtime.rs:59),它先算一件事:
changed |= crate::api::request_changes_ui(&msg.request);
request_changes_ui 是一张硬编码的白名单,列了 56 个「会改变屏幕」的方法(src/api/mod.rs:22-82)。命中就置脏、触发重渲染;像 pane.read、agent.get、workspace.list 这些只读方法不在表里,于是一个 agent 高频轮询读屏不会给渲染管线增加任何负担。这条线和 渲染管线 那章的「多路乘法性能路径」是同一件事的两端。
表里有个可直接读出的不对称:plugin.disable 在表内、plugin.enable 不在(src/api/mod.rs:76)。
真正的处理落在 handle_api_request 的一个大 match(src/app/api.rs:939 起),按域分派到 src/app/api/{panes,agents,tabs,workspaces,worktrees,plugins,layouts}.rs。
4.5 例外:worktree 的延迟应答
worktree.create 和 worktree.remove 要跑真正的 git worktree add/remove 子进程,不能在事件循环里同步等。所以它们被单独拎出来:
handle_api_request_message
└─ 是 WorktreeCreate/Remove?
├─ 是 → drain 内部事件 → handle_deferred_worktree_api_request(request, respond_to)
│ └─ 把 respond_to 【存起来】,git 跑完后由完成事件回填 ← 此时不回信
└─ 否 → handle_api_request(...) → 立即 respond_to.send(response)
依据:src/app/runtime.rs:76-89 与 src/app/api/worktrees/deferred.rs:15-31。客户端侧毫无感知——它只是在 dispatch_to_app 的无超时 recv() 上多等了一会儿。
5. 阻塞式原语(本章最关键的一节)
5.1 要解决的小问题
CLI 世界里,「命令返回了」等于「事情做完了」。但 agent 不是这样:agent.prompt 只是往 PTY 里写了几个字节,写完就返回了——此时 agent 一个 token 都还没吐。
于是控制面必须提供一个东西:一个会在"事情做完"时才返回的调用。herdr 给了三个,语义各不相同。
5.2 三者对比
| 原语 | 等什么 | 驱动方式 | 谁调用 |
|---|---|---|---|
pane.wait_for_output | 屏幕上出现某个子串/正则 | 纯轮询 pane.read | 任何 pane(跑测试、跑 server) |
agent.wait | agent 状态进入某个集合 | 事件驱动 + 探针复核 | 已经在跑的 agent |
agent.prompt --wait | 投喂之后 agent 走完一轮 | 两段式:先证明有反应,再等落定 | 投喂 + 等待一步完成 |
events.wait | 一个事件匹配 | 复用订阅机制 | 只支持一种匹配(见下) |
5.3 pane.wait_for_output:最朴素的那个
思路直白:循环 → 读屏 → 匹配 → 命中就返回,否则睡 100ms。核心就这几步(src/api/wait.rs:22-129):
let matched_line = match_output(&read.text, ¶ms.r#match, regex.as_ref());
if matched_line.is_some() { /* 返回 output_matched */ }
if deadline.is_some_and(|d| std::time::Instant::now() >= d) { /* 返回 timeout */ }
std::thread::sleep(CONNECTION_POLL_INTERVAL);
三个不显然的细节:
- 正则先编译再进循环。编译失败直接返回
invalid_regex,不会在循环里反复失败(src/api/wait.rs:34-51)。 - 匹配是逐行的,
match_output对text.lines()找第一条命中行(src/api/subscriptions.rs:20-36)。所以跨行的正则匹配不到。 - 读取口径会被悄悄改写:
output_match_read_source把Recent换成RecentUnwrapped(src/api/subscriptions.rs:11-18)。原因很实际——终端软换行会把test result: ok从中间劈开,不解包就匹配不上。 - 它不区分"新旧"。第一次读到的快照里如果已经有目标文本,立刻就命中。SKILL.md 明写了这一点(
skills/herdr/SKILL.md:172)。
5.4 agent.wait:事件驱动 + 探针复核
思路: 光轮询 agent.get 太浪费,光信事件又可能漏。herdr 的做法是事件只当"该去看一眼"的触发器,真值永远来自一次探针。
EventHub.events_after(seq)
│
┌──────────┴───────────┐
│ 与本 pane 相关的事件? │
└──────────┬───────────┘
│ 是
┌───────┴────────┐
│ 生命周期类事件? │──是──▶ PaneClosed/PaneExited/PaneMoved → agent_not_running(直接失败)
└───────┬────────┘
│ 否(状态变化/pane 更新)
should_probe = true
│
agent.get 探针 ──▶ 身份还对得上吗? ──否──▶ agent_not_running
│ 对
状态 ∈ until ? ──是──▶ 返回 AgentInfo
依据:wait_for_resolved_agent(src/api/wait.rs:348-498),身份校验 agent_wait_identity_matches(src/api/wait.rs:525-538)。
几个精华点:
- 进门先查一次。
wait_for_agent在进循环前就做一次agent_get,若当前状态已满足直接返回(src/api/wait.rs:141-152)。这意味着agent.wait匹配的是状态,不是转换。 - 默认
until是三个"落定态":idle/done/blocked(agent_wait_statuses,src/api/wait.rs:511-523)。working和unknown要显式指定——因为它们不代表"该回来找我了"。 - 身份漂移视为失败。pane 被移走、agent 换了 kind、terminal_id 变了,一律返回
agent_not_running而不是傻等(src/api/wait.rs:400-436)。 - 只有
agent_not_found会被翻译成agent_not_running,其它探针错误(比如server_unavailable)原样透传——有专门的测试盯着(src/api/wait.rs:787-811)。 - 客户端断开即取消。每轮循环开头
should_stop_connection会检查 peer 是否关了 socket(src/api/server.rs:775-784);断开就静默收工,不写响应。Ctrl-C 一个herdr agent wait就能干净 地取消服务端的等待,不需要任何 cancel 方法。
5.5 agent.prompt --wait:两段式与 stall 保护
这是整个控制面里最精细的一段逻辑,也是最值得学的。
它要解决的坑: 你投喂了 prompt,然后等 idle。但 agent 本来就是 idle——如果它根本没收到你的输入(TUI 吞了、焦点丢了、粘贴模式不对),你会立刻拿到一个"成功"的 idle,然后开开心心去读一个空结果。
herdr 的解法:先要求看到"有反应"的证据,再去等"落定"。
prompt 提交
│
├─ 提交前状态 == Working? ──是──▶ 跳过第一段(它本来就在动)
│
└─ 否 ── 第一段:证明有反应 ───────────────────────────────┐
until = 【全部 5 个状态】 │
条件 = state_change_seq > 提交时的基线 │
超时 = min(用户 timeout, 5000ms) │
│ │
├─ 没等到 ──▶ 用户 timeout > 5s → agent_prompt_stalled
│ 用户 timeout ≤ 5s → timeout
└─ 等到 ────▶ 第二段:等落定(until = idle/done/blocked)
超时 = 用户 timeout 的剩余部分
关键源码锚点(src/api/wait.rs:226-305):
let effect_timeout_ms = wait.timeout_ms
.map_or(AGENT_PROMPT_EFFECT_TIMEOUT_MS, |t| t.min(AGENT_PROMPT_EFFECT_TIMEOUT_MS));
AGENT_PROMPT_EFFECT_TIMEOUT_MS = 5_000(src/api/wait.rs:20)。「有反应」的判据是 agent_wait_matches 里那个 after_state_change_seq 门槛(src/api/wait.rs:540-547)——必须看到状态序号严格递增,光"状态值相同"不算数。
第一段用的 until 是 all_agent_statuses(),注释写得很清楚:每一个状态都是"序号前进了"的证据(src/api/wait.rs:500-509)。
错误信息也做得很实在,把诊断信息直接塞进 message(src/api/wait.rs:626-629):
agent prompt produced no observed state change within 5000 ms;
status is idle and state_change_seq remained 41
还有一条诚实的边界,herdr 自己在 CLI 帮助文本里写明了:它跟踪的是生命周期状态,不是一个 turn;如果 agent 本来就在 working,那个正在进行的 turn 结束也会算数(src/cli/spec.rs:367)。这是检测机制的固有限制,不是 bug。
5.6 events.wait 与 EventHub
EventHub 是个极简结构:一个 Mutex<Vec<(u64, EventEnvelope)>>,自增序号,上限 512 条,溢出从头 drain(src/api/event_hub.rs:13-26)。等待方靠 events_after(seq) 拉增量。
512 条是有含义的:一个等待方如果 100ms 内没被调度,而这期间涌进了 512 条以上事件,它就会漏掉最早的那些。这也是为什么 agent.wait 不敢只信事件——必须配探针复核。
events.wait 复用了订阅机制:把 EventMatch 翻成一个 Subscription,包成 ActiveSubscription,然后循环 poll_for_wait(src/api/wait.rs:661-715)。
但它现在只支持一种匹配。 EventMatch 枚举里定义了 19 种(src/api/schema/events.rs:116-190,WorkspaceCreated 到 PaneAgentStatusChanged),而 event_match_subscription 只认 PaneAgentStatusChanged,其余一律返回 unsupported_event_wait_match(src/api/wait.rs:717-737)。schema 比实现宽——用之前要看这一点。
5.7 订阅流:去重与"快照 vs 事件"的赛跑
events.subscribe 是长连接:握手先回一条 subscription_started,之后每 100ms 轮询所有订阅(src/api/server.rs:686-742)。有两处值得学:
- 输出匹配订阅有边沿去重:
currently_matching标志保证同一段持续存在的文本只推一次事件(src/api/subscriptions.rs:357-376)。 - 状态订阅会处理"快照和事件流赛跑":在拿 pane 快照前后各读一次
event_hub.current_sequence(),若期间来了新事件就丢弃这次快照、下轮重来(src/api/subscriptions.rs:454-470)。事件流永远优先于轮询快照。
6. agent 生命周期动作
6.1 agent.start:三道门 + 一个服务端/客户端分工
服务端侧的 start_agent 是纯校验 + 发字节,它不等(src/app/agents.rs:145-227)。它检查:
| 检查 | 失败错误码 | 依据 |
|---|---|---|
| 名字合法 | invalid_agent_name | valid_agent_name,src/app/agents.rs:15-20 |
| kind 受支持 | unsupported_agent_kind | src/app/agents.rs:153 |
| 参数无控制字符 | invalid_agent_argument | src/app/agents.rs:156-162 |
| 名字未被占用 | agent_name_taken | agent_name_conflicts,src/app/agents.rs:399 |
| 目标 pane 是空闲 shell | agent_pane_busy | src/app/agents.rs:185-193 |
| 超时在窗口内 | invalid_agent_timeout | src/app/agents.rs:205-207 |
三个时间常量(src/app/agents.rs:8-10):
| 常量 | 值 | 含义 |
|---|---|---|
DEFAULT_AGENT_START_TIMEOUT | 30s | 未指定 --timeout 时的启动等待上限 |
MAX_AGENT_START_TIMEOUT | 300s | 允许的最大值 |
AGENT_START_SETTLE_DELAY | 3s | 允许的下界——超时必须 > 它 |
超时的合法区间是 (3000ms, 300000ms],而不是 [0, 300000]。理由很实在:agent 启动头几秒屏幕上什么都还没有,3 秒以内的超时必然误判。
名字语法故意收得很窄:[a-z][a-z0-9_-]{0,31}。测试直接把设计意图写在名字里——agent_names_use_a_small_cli_safe_grammar(src/app/agents.rs:474)。这是为了让名字能安全地当 CLI 位置参数,不需要引号。
"等它真的能用"这一步在客户端做。 wait_for_named_agent 在 CLI 里每 100ms 轮询 agent.get,并做一张真值表(src/cli/agent.rs:548-618):
| 观察到 | 判定 |
|---|---|
terminal_id 变了 / name 丢了 | agent_name_lost(失败) |
| 检测到的 kind ≠ 请求的 kind | agent_kind_mismatch(失败) |
blocked | agent_not_ready(失败,启动期就卡住了) |
working / unknown | 继续等 |
idle/done 且 interactive_ready | 成功 |
idle/done 且 不是 launch_pending | agent_start_failed(进程起来又死了) |
最后一行是全表最妙的:没在启动中、又已经空闲、却从没进过可交互态 = 进程启动后立刻退出了。这个组合把"命令不存在""立刻崩了"从"还没起来"里区分了出来。
启动路径上还有一层重试:碰到 agent_pane_busy 时,如果那个 pane 看起来只是 shell 还在初始化(读 pane.process_info 判断),就在 2 秒窗口内重试(src/cli/agent.rs:341-390)。
6.2 agent.prompt:agent_blocked 是前置拒绝
这是一条重要的语义:
if terminal.state == crate::detect::AgentState::Blocked {
return encode_error(id, "agent_blocked", format!("agent {} is blocked and requires interactive input", params.target));
}
src/app/api/agents.rs:82-91。注意它在任何字节发出去之前。含义是:如果 agent 正停在一个审批/提问对话框上,herdr 不会把你的 prompt 当成对话框的答案打进去。这避免了一个很危险的失误——把 "Review the diff" 敲进一个 "Allow write to /etc? (y/n)" 的提示符。SKILL.md 把处置方式也写死了:先看那个对话框,再问人(skills/herdr/SKILL.md:128)。
提交本身分两拍:先写文本,300ms 后再写回车(AGENT_PROMPT_SUBMIT_DELAY,src/app/api/agents.rs:13,126)。GitHub Copilot 还要额外先补一个 focus-gained 序列,因为它在失焦后会忽略合成的 Enter(src/app/api/agents.rs:111-120)。
7. 读取口径:ReadSource 与 ReadIntent
7.1 四种读法
ReadSource 有四个取值(src/api/schema/common.rs:62),不是同一份数据的四种格式,而是四个不同的取样面:
| 取值 | 取的是什么 | 什么时候用 |
|---|---|---|
visible | 当前渲染的视口 | 想看"用户此刻看到什么" |
recent | 近期输出,保留软换行 | 想还原屏幕排版 |
recent_unwrapped | 近期输出,软换行接回一行 | 日志和长文本首选 |
detection | agent 检测用的纯文本底部缓冲快照 | 调检测规则时用 |
detection 这一档专门存在,是因为用户可以滚动视口——拿视口做状态判断会被用户的滚轮弄错。这条约束和 agent 检测 那章是一体的。
7.2 ReadIntent 为什么必须区分
PaneReadParams 里有个字段带着 #[serde(skip)] + #[schemars(skip)](src/api/schema/panes.rs:284-286):
pub(crate) intent: super::common::ReadIntent,
它不上线协议、不进 schema——纯粹是服务端内部标记谁发起的这次读。取值只有 Interactive(默认)和 Passive(src/api/schema/common.rs:69-74)。
区别在于:一次"读"其实可能有副作用。 当 agent 跑在 alt screen(备用屏)上时,历史行不会进 herdr 的 host scrollback,普通读法拿不到。herdr 的对策是真的去滚那个 pane:注入向上滚轮事件、逐屏收割、再滚回原位(PendingAltScreenRead,src/server/alt_screen_read.rs:27,总时限 15 秒)。