@muzi3/herdr 0.2.3 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,33 +1,37 @@
1
1
  /**
2
- * Herdr pane / agent 工具:`herdr_layout`、`herdr_pane`、`herdr_agent`。
2
+ * Herdr 管理工具面:八个动词工具,逐个操作对齐 dsh 的
3
+ * `@muzi3/dsh-plugin-herdr-tools` manager 面(`src/manager.js`):
3
4
  *
4
- * Fork 自 `@ogulcancelik/pi-herdr` 0.4.0(MIT,Copyright (c) 2026 Can Celik)。
5
- * 上游许可全文见同包根目录 `LICENSE.MIT`,来源与改动说明见 README。
5
+ * herdr_overview · herdr_read · herdr_wait · herdr_prompt
6
+ * herdr_run · herdr_create · herdr_agent_start · herdr_close
6
7
  *
7
- * 相对上游的改动(3 行):`pane run` / `pane send-text` / `pane send-keys` 在
8
- * herdr 0.9.0 上成功时退出码为 0 且 stdout 为空(0.9.5 复测同)。上游用 execHerdrJson 会额外要求
9
- * JSON envelope,于是每一次写操作都误报 "Expected JSON output from herdr ..."
10
- * —— 即使命令早已成功下发到目标 pane。
8
+ * 与 dsh 的默认面(`herdr_layout` / `herdr_pane` / `herdr_agent` 三个 action 风格
9
+ * 工具)不同,这里一个动词一个工具。pi 早期版本用的是 action 面,已整体替换:
10
+ * 两套不共存,否则同名能力会有两种调用方式,模型选哪套全凭运气。
11
11
  *
12
- * 这三处改用 execHerdr:它只校验退出码,并把 stderr/stdout 上的结构化 herdr 错误
13
- * 经 parseHerdrError 抛出,所以真实失败照旧报错,只有那个不成立的“stdout 必须非空”
14
- * 约束被去掉。`pane close` / `pane wait-output` / `agent send-keys` 确实返回 JSON
15
- * envelope,保持 execHerdrJson。回归测试见 tests/pane-agent-tools.test.ts。
12
+ * 复用了 dsh 侧的全部判据语义:
13
+ * - 统一信封 `{ ok, exitCode, timedOut?, output, note?, via? }`。exitCode:0 成功 /
14
+ * 1 herdr 错误 / 2 参数或前置校验失败 / -1 本地超时。`note` 是判据提示,渲染在正文前。
15
+ * - 每次 herdr 调用都有本地截止时间(默认 120s);本地超时是「没观测到」,不是失败:
16
+ * 返回 timedOut 而不是把错误抛给调用方。
17
+ * - herdr 的 `timeout` / `agent_prompt_stalled` 都是「没观测到」,不是失败。stalled 尤其
18
+ * 危险 —— prompt 已经下发,重发就是重复下发。
19
+ * - `agent prompt` 只有 herdr 自己命名并激活的 agent 才收;自报状态的 agent(dsh-tui)
20
+ * 回 `agent_not_ready`,此时回退成 `pane run` 直接键入,`via` 说明实际走了哪条路。
21
+ * - `herdr_read` 默认 digest:只回「前段命中的审批/报错行 + 尾部 30 行」,要看整段传
22
+ * `mode: "raw"`。失败从不压缩 —— 错误原文本身就是证据。
23
+ * - 下发类工具(prompt / run / close)拒绝以自己所在的 pane 为目标:那等于给自己喂输入,
24
+ * 会打断正在进行的这一轮。prompt 另外拒绝插队正在 working 的 agent(除非 force)。
16
25
  *
17
- * 状态检测加固:
18
- * - herdr 的 error.code 会随错误一起抛出(herdrError/herdrErrorCode)。timeout 与
19
- * agent_prompt_stalled 表示“没观测到”,不是失败:前者降级为 matched:false +
20
- * pane 尾部输出,后者降级为 submitted:true + stalled:true,避免调用方误判为命令
21
- * 失败,或因为一条假错误把 prompt 重复下发一遍。
22
- * - wait_output 的 match 未显式指定 regex 时,含正则元字符的 pattern 按正则处理。
23
- * 字面量语义下 `a|b` / `log \$` 永远不可能命中,只会白等满 timeout。
24
- * - 命中行包含本进程刚下发的命令文本时标 echoedInput —— 那多半是命令回显行,而不是
25
- * 命令产生的输出(回显在命令还没跑完时就已经存在)。
26
- * - agent_blocked 单独识别:目标卡在审批/提问弹窗时 herdr 会在发送任何输入前就拒绝,
27
- * 因此不能像 stalled 那样判为“已下发”,也不能靠重试绕过(详见 isPromptBlocked)。
26
+ * pi 侧保留的两处加固(dsh manager 面没有,见 README):
27
+ * - `agent_blocked`:目标卡在审批/提问弹窗时 herdr 在发送任何输入**之前**就拒绝,所以判为
28
+ * 未下发(exitCode 2)并指向「先看清阻塞界面」,而不是 `agent_prompt_stalled` 那样判为
29
+ * 已下发。
30
+ * - `agent start --kind` 用枚举约束,必须与 `herdr agent start --help` 一致(0.9.5 共 24 个),
31
+ * tests/ 里有对账用例。
28
32
  *
29
- * 维护:`agent start --kind` 的枚举必须与 `herdr agent start --help` 一致(0.9.5 共 24 个)。
30
- * herdr 每加一个 agent 就多一个 kind,枚举写死就会让新 kind 起不来 —— tests/ 里有对账用例。
33
+ * 环境变量(pi 侧用 PI_ 前缀,语义同 dsh 的 DSH_HERDR_*):`PI_HERDR_PROMPT_GUARD`(默认 on,
34
+ * 置 off 关闭插队保护)、`PI_HERDR_READ_MODE`(默认 digest)、`PI_HERDR_READ_TAIL_LINES`(默认 30)。
31
35
  */
32
36
 
33
37
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
@@ -37,10 +41,8 @@ import { Text } from "@earendil-works/pi-tui";
37
41
  import { Type } from "typebox";
38
42
 
39
43
  type AgentStatus = "idle" | "working" | "blocked" | "done" | "unknown";
40
- type ReadSource = "visible" | "recent" | "recent-unwrapped" | "detection";
41
- type WaitOutputSource = Exclude<ReadSource, "detection">;
42
44
  type SplitDirection = "right" | "down";
43
- type OutputFormat = "text" | "ansi";
45
+ type ReadMode = "digest" | "raw";
44
46
 
45
47
  interface WorkspaceInfo {
46
48
  workspace_id: string;
@@ -49,14 +51,6 @@ interface WorkspaceInfo {
49
51
  agent_status: AgentStatus;
50
52
  }
51
53
 
52
- interface TabInfo {
53
- tab_id: string;
54
- workspace_id: string;
55
- label: string;
56
- focused: boolean;
57
- agent_status: AgentStatus;
58
- }
59
-
60
54
  interface PaneInfo {
61
55
  pane_id: string;
62
56
  workspace_id: string;
@@ -106,26 +100,53 @@ interface HerdrJsonEnvelope {
106
100
  };
107
101
  }
108
102
 
109
- interface WaitOutputResult {
110
- pane_id: string;
111
- matched_line: string;
112
- read?: { text?: string };
103
+ /** 八个工具共用的结果信封:渲染成正文,同时作为 `details` 交给渲染层。 */
104
+ interface Envelope {
105
+ ok: boolean;
106
+ exitCode: number;
107
+ timedOut?: boolean;
108
+ output: string;
109
+ note?: string;
110
+ via?: string;
113
111
  }
114
112
 
113
+ /** 一次 herdr 调用的结局:成功 / 本地截止时间到期 / 真失败。 */
114
+ type Failure =
115
+ | { ok: false; timedOut: false; error: unknown }
116
+ | { ok: false; timedOut: true; error?: undefined };
117
+ type Attempt<T> = { ok: true; value: T } | Failure;
118
+
115
119
  const StatusEnum = StringEnum(["idle", "working", "blocked", "done", "unknown"] as const, {
116
- description: "Agent lifecycle state",
120
+ description: "要等的 agent 生命周期状态",
117
121
  });
118
122
 
119
123
  const ReadSourceEnum = StringEnum(["visible", "recent", "recent-unwrapped", "detection"] as const, {
120
- description: "Terminal snapshot source",
124
+ description: "终端快照来源,默认 recent-unwrapped(日志与长文本首选)",
121
125
  });
122
126
 
123
- const OutputFormatEnum = StringEnum(["text", "ansi"] as const, {
124
- description: "Output format; ansi preserves terminal styling",
127
+ const ReadModeEnum = StringEnum(["digest", "raw"] as const, {
128
+ description: "默认 digest:压成「前段信号行 + 尾部」;要整段原文传 raw",
129
+ });
130
+
131
+ /**
132
+ * 八个工具共用的结构化结果:codemode 脚本拿它做过滤,模型仍看 content 里的渲染文本。
133
+ * 与 Envelope 同形,免得多出一套真值。
134
+ */
135
+ const envelopeOutputSchema = Type.Object({
136
+ ok: Type.Boolean({ description: "这条 herdr 命令是否成功" }),
137
+ exitCode: Type.Number({ description: "退出码;-1 表示本地截止时间到期" }),
138
+ timedOut: Type.Optional(Type.Boolean({ description: "本地截止时间是否到期" })),
139
+ output: Type.String({ description: "正文:digest 压缩后的信号行 + 尾部,或 raw 全文" }),
140
+ note: Type.Optional(Type.String({ description: "判据提示,或失败时的说明" })),
141
+ via: Type.Optional(Type.String({ description: "实际走了 agent 还是 pane 视图" })),
142
+ });
143
+
144
+ const CreateKindEnum = StringEnum(["workspace", "tab", "pane"] as const, {
145
+ description: "建 workspace、建 tab,还是切一个兄弟 pane",
125
146
  });
126
147
 
127
148
  const DirectionEnum = StringEnum(["right", "down"] as const, {
128
- description: "Split direction. When omitted, the tool chooses from the source pane geometry.",
149
+ description: "pane 切分方向,默认从几何信息判断",
129
150
  });
130
151
 
131
152
  const AgentKindEnum = StringEnum(
@@ -155,14 +176,53 @@ const AgentKindEnum = StringEnum(
155
176
  "maki",
156
177
  "muse",
157
178
  ] as const,
158
- { description: "Supported coding agent kind and canonical executable" },
179
+ { description: "agent 种类(如 claude / codex / pi)。必须与 `herdr agent start --help` 一致" },
159
180
  );
160
181
 
182
+ /** 每次 herdr 调用的本地截止时间:卡死的 herdr 不能把 manager 一起挂住。 */
183
+ const DEFAULT_LOCAL_TIMEOUT = 120_000;
184
+
161
185
  /**
162
- * 字面量里带这些字符的 pattern(`a|b`、`log \$`)在字面量语义下永远不可能命中,
163
- * 只会白等满 timeout,所以 wait_output 在未显式指定 regex 时按正则解释它们。
186
+ * herdr_wait 的本地截止时间比它向 herdr 要的等待略长,让 herdr 自己的 timeout 先到;
187
+ * 本地那条只兜「herdr 卡死」。
164
188
  */
165
- const REGEX_METACHARS = /[|()[\]{}^$*+?\\]/;
189
+ const WAIT_LOCAL_MARGIN = 30_000;
190
+ const WAIT_DEFAULT_TIMEOUT = 60_000;
191
+
192
+ /** 插队保护:lancer 正在 working 的 agent 时默认拒绝下发。 */
193
+ const PROMPT_GUARD_ON = (process.env.PI_HERDR_PROMPT_GUARD ?? "on") !== "off";
194
+
195
+ const readTailFromEnv = Number(process.env.PI_HERDR_READ_TAIL_LINES ?? 30);
196
+ const READ_TAIL_LINES = Number.isFinite(readTailFromEnv) && readTailFromEnv > 0 ? readTailFromEnv : 30;
197
+ const READ_MODE_DEFAULT: ReadMode = process.env.PI_HERDR_READ_MODE === "raw" ? "raw" : "digest";
198
+
199
+ /**
200
+ * digest 从前段捞出来的信号行:审批、报错、超时。判据和 dsh 侧一致 —— manager 的
201
+ * 「有没有东西在等人」判断是按这批词训出来的。
202
+ */
203
+ const SIGNAL_RE =
204
+ /(\[y\/N\]|\(y\/n\)|是否|确认|同意|approve|allow|permission|do you want|waiting for|error|exception|traceback|failed|失败|报错|超时|?|\?$)/i;
205
+
206
+ /** herdr_overview 每个列表默认返回多少条,与 dsh 侧一致。 */
207
+ const OVERVIEW_LIST_LIMIT = 50;
208
+
209
+ /** agent 名字的约束(herdr CLI 自己也是这条)。 */
210
+ const AGENT_NAME_PATTERN = /^[a-z][a-z0-9_-]{0,31}$/;
211
+
212
+ /** herdr_agent_start 的 timeout 边界,dsh 侧同样在 body 里判。 */
213
+ const START_TIMEOUT_MIN = 3_000;
214
+ const START_TIMEOUT_MAX = 300_000;
215
+
216
+ /** exitCode 的语义,让 note 读起来和 dsh 侧一样。 */
217
+ const EXIT_NOTE: Record<number, string> = {
218
+ 1: "herdr 侧错误(消息来自它的结构化错误)",
219
+ 2: "参数或前置校验失败(没有下发给 herdr)",
220
+ };
221
+
222
+ const LOCAL_TIMEOUT_NOTE = "本地等待超时,未观测到结果(不等于命令失败);用 herdr_wait / herdr_read 复查再决定。";
223
+
224
+ const SELF_PANE_REFUSAL =
225
+ "拒绝对自己所在的 pane 下发:那等于给自己喂输入,会打断你正在进行的这一轮。要操作别的 pane,先从 herdr_overview 拿完整 pane id。";
166
226
 
167
227
  interface HerdrErrorDetail {
168
228
  code?: string;
@@ -182,7 +242,7 @@ function parseHerdrError(output: string): HerdrErrorDetail | null {
182
242
  }
183
243
  }
184
244
 
185
- /** 构造带 herdr code 的错误,让调用方能区分“检测未达成”和真正的失败。 */
245
+ /** 构造带 herdr code 的错误,让调用方能区分「检测未达成」和真正的失败。 */
186
246
  function herdrError(message: string, code?: string): Error {
187
247
  const error = new Error(message) as Error & { herdrCode?: string };
188
248
  if (code) error.herdrCode = code;
@@ -193,17 +253,22 @@ function herdrErrorCode(error: unknown): string | undefined {
193
253
  return error instanceof Error ? (error as Error & { herdrCode?: string }).herdrCode : undefined;
194
254
  }
195
255
 
256
+ const errorMessage = (error: unknown): string => (error instanceof Error ? error.message : String(error));
257
+
196
258
  /**
197
- * herdr 的 timeout / agent_prompt_stalled 表示“没观测到”,不是失败。
198
- * code 是主要判据,文案匹配作为兜底(个别版本可能不带结构化 code)。
199
- * stalled 的文案见过两种:旧版 `no observed state change ... state_change_seq remained N`,
200
- * 0.9.0 起 `no observed working or blocked state within 5000 ms; current status is idle`
201
- * (0.9.5 二进制里仍是这段措辞)。
259
+ * herdr 的 timeout 表示「没观测到」。code 是主判据,文案匹配作为兜底
260
+ * (个别版本可能不带结构化 code)。
202
261
  */
203
262
  function isHerdrTimeout(error: unknown): boolean {
204
263
  return herdrErrorCode(error) === "timeout" || (error instanceof Error && /timed out waiting for/i.test(error.message));
205
264
  }
206
265
 
266
+ /**
267
+ * `agent_prompt_stalled` 是「submission accepted」之后 5s 内没观测到 working/blocked,
268
+ * prompt 已经下发 —— 这是启发式,不能当成下发失败。文案见过两种:旧版
269
+ * `no observed state change ... state_change_seq remained N`,0.9.0 起
270
+ * `no observed working or blocked state within 5000 ms; current status is idle`。
271
+ */
207
272
  function isPromptStalled(error: unknown): boolean {
208
273
  return (
209
274
  herdrErrorCode(error) === "agent_prompt_stalled" ||
@@ -214,7 +279,7 @@ function isPromptStalled(error: unknown): boolean {
214
279
  /**
215
280
  * 目标正卡在审批/提问弹窗时,herdr 在**发送任何输入之前**就拒绝 prompt,返回 agent_blocked
216
281
  * (见 0.9.5 的 `herdr agent prompt --help` 与 api/agents.rs 的 validate_agent_prompt)。
217
- * 这和 stalled 相反:它确定没下发,且不该自动重试 —— 必须先看阻塞的界面再由人决定怎么答。
282
+ * 这和 stalled 相反:它确定没下发,也不该自动重试 —— 必须先看清阻塞的界面再由人决定怎么答。
218
283
  */
219
284
  function isPromptBlocked(error: unknown): boolean {
220
285
  return herdrErrorCode(error) === "agent_blocked";
@@ -233,27 +298,15 @@ function formatOutput(output: string): string {
233
298
  return `[Showing last ${truncation.outputLines} of ${truncation.totalLines} lines]\n${truncation.content}`;
234
299
  }
235
300
 
236
- function chooseSplitDirection(layout: PaneLayoutSnapshot, paneId: string): SplitDirection {
301
+ function chooseSplitDirection(
302
+ layout: { panes: Array<{ pane_id: string; rect: PaneLayoutRect }> },
303
+ paneId: string,
304
+ ): SplitDirection {
237
305
  const pane = layout.panes.find((candidate) => candidate.pane_id === paneId);
238
306
  if (!pane) return "right";
239
307
  return pane.rect.width >= 80 && pane.rect.width >= pane.rect.height * 2 ? "right" : "down";
240
308
  }
241
309
 
242
- function statusDot(theme: any, status: AgentStatus): string {
243
- switch (status) {
244
- case "blocked":
245
- return theme.fg("warning", "●");
246
- case "working":
247
- return theme.fg("accent", "●");
248
- case "done":
249
- return theme.fg("success", "●");
250
- case "idle":
251
- return theme.fg("muted", "○");
252
- default:
253
- return theme.fg("dim", "·");
254
- }
255
- }
256
-
257
310
  function agentDisplayName(agent: AgentInfo): string {
258
311
  return agent.name || agent.display_agent || agent.agent || agent.pane_id;
259
312
  }
@@ -275,13 +328,6 @@ function summarizePane(pane: PaneInfo, currentPaneId?: string): string {
275
328
  return `${pane.label || pane.pane_id}: [${pane.pane_id}]${flags ? ` (${flags})` : ""}${cwd ? ` ${cwd}` : ""}`;
276
329
  }
277
330
 
278
- function summarizeTab(tab: TabInfo): string {
279
- const flags = [tab.focused ? "focused" : null, tab.agent_status !== "unknown" ? tab.agent_status : null]
280
- .filter(Boolean)
281
- .join(", ");
282
- return `${tab.label}: [${tab.tab_id}]${flags ? ` (${flags})` : ""}`;
283
- }
284
-
285
331
  function summarizeWorkspace(workspace: WorkspaceInfo): string {
286
332
  const flags = [
287
333
  workspace.focused ? "focused" : null,
@@ -292,75 +338,149 @@ function summarizeWorkspace(workspace: WorkspaceInfo): string {
292
338
  return `${workspace.label}: [${workspace.workspace_id}]${flags ? ` (${flags})` : ""}`;
293
339
  }
294
340
 
295
- function renderToolCall(tool: string, args: Record<string, any>, theme: any, context: any) {
341
+ /**
342
+ * 截断一个 overview 列表。静默截断会被读成「全机就这些」—— 对 manager 恰是错的结论,
343
+ * 所以永远写出被隐藏的条数,让调用方自己决定抬 limit。
344
+ */
345
+ function capList<T>(items: T[], limit: number, kind: string, summarize: (item: T) => string): string {
346
+ if (!items.length) return `No ${kind}.`;
347
+ const shown = items.slice(0, limit);
348
+ const body = shown.map(summarize).join("\n");
349
+ return shown.length < items.length ? `${body}\n[Showing ${shown.length} of ${items.length} ${kind} — raise with limit]` : body;
350
+ }
351
+
352
+ /**
353
+ * 把读屏压成「前段命中的信号行 + 原样尾部」。
354
+ *
355
+ * 为什么不是单纯截断:manager 既要判断又要引用这段文字,尾部必须原样留着(那是证据),
356
+ * 而更早的审批/失败行值得提上来。与 dsh 侧同一份实现,保证两边看到同一幅画面。
357
+ */
358
+ function digestRead(output: string, tailLines: number): string {
359
+ const lines = output.split("\n");
360
+ let end = lines.length;
361
+ while (end > 0 && lines[end - 1].trim() === "") end -= 1;
362
+ const body = lines.slice(0, end);
363
+ if (body.length <= tailLines) return output;
364
+ const tail = body.slice(-tailLines);
365
+ const head = [`(读屏已压缩:原始 ${body.length} 行 → 尾部 ${tail.length} 行;要全量传 mode="raw")`];
366
+ const signals: string[] = [];
367
+ for (let i = 0; i < body.length - tail.length; i += 1) {
368
+ if (SIGNAL_RE.test(body[i])) signals.push(`[${i + 1}] ${body[i]}`);
369
+ }
370
+ if (signals.length > 0) head.push("## 前段命中的信号行(审批/报错)", ...signals.slice(-20));
371
+ head.push("## 尾部(原样)", ...tail);
372
+ return head.join("\n");
373
+ }
374
+
375
+ /** 信封渲染:note 是判据提示,所以排在最前面。 */
376
+ function renderEnvelope(value: Envelope): string {
377
+ const head = value.note === undefined ? "" : `${value.note}\n`;
378
+ const body = value.output.length > 0 ? value.output : "(无输出)";
379
+ return (head + body).trim();
380
+ }
381
+
382
+ function envelopeResult(value: Envelope) {
383
+ return {
384
+ content: [{ type: "text" as const, text: renderEnvelope(value) }],
385
+ details: value,
386
+ // 声明了 outputSchema 的工具:脚本收结构化值,模型仍收上面的渲染文本。
387
+ structuredContent: value,
388
+ };
389
+ }
390
+
391
+ /** 自己拒绝的决定,发生在任何 herdr 调用之前。 */
392
+ const refusal = (note: string): Envelope => ({ ok: false, exitCode: 2, output: "", note });
393
+
394
+ /** 来自 herdr 侧前置条件的拒绝。 */
395
+ const precondition = (message: string): Envelope => ({
396
+ ok: false,
397
+ exitCode: 2,
398
+ output: message,
399
+ note: EXIT_NOTE[2],
400
+ });
401
+
402
+ /** 失败结局映射成信封。成功时不会调用它。 */
403
+ function failureEnvelope(outcome: Failure, extra: Partial<Envelope> = {}): Envelope {
404
+ if (outcome.timedOut) {
405
+ return { ok: true, exitCode: -1, timedOut: true, output: "", note: LOCAL_TIMEOUT_NOTE, ...extra };
406
+ }
407
+ return { ok: false, exitCode: 1, output: errorMessage(outcome.error), note: EXIT_NOTE[1], ...extra };
408
+ }
409
+
410
+ /** 失败结局的一行摘要,用于 via 里说明回退原因。 */
411
+ function failureLine(outcome: Failure): string {
412
+ return outcome.timedOut ? "本地超时" : errorMessage(outcome.error).split("\n")[0];
413
+ }
414
+
415
+ /**
416
+ * 在本地截止时间下跑一次 herdr 调用。
417
+ *
418
+ * 截止时间是组合 signal,而不是第二条调用路径:execHerdr 本来就跟随 signal 中断,
419
+ * 所以这里只需把「调用方取消」(重抛)和「我们的截止时间到了」(timedOut)分开。
420
+ */
421
+ async function attempt<T>(
422
+ signal: AbortSignal | undefined,
423
+ timeoutMs: number | undefined,
424
+ operation: (signal: AbortSignal) => Promise<T>,
425
+ ): Promise<Attempt<T>> {
426
+ const controller = new AbortController();
427
+ const timer = timeoutMs == null ? null : setTimeout(() => controller.abort(), timeoutMs);
428
+ const combined = signal ? AbortSignal.any([signal, controller.signal]) : controller.signal;
429
+ try {
430
+ return { ok: true, value: await operation(combined) };
431
+ } catch (error) {
432
+ if (signal?.aborted === true) throw error;
433
+ if (controller.signal.aborted) return { ok: false, timedOut: true };
434
+ return { ok: false, timedOut: false, error };
435
+ } finally {
436
+ if (timer !== null) clearTimeout(timer);
437
+ }
438
+ }
439
+
440
+ function renderVerbCall(tool: string, args: Record<string, any>, theme: any, context: any) {
296
441
  const component = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0);
297
442
  let text = theme.fg("toolTitle", theme.bold(`${tool} `));
298
- text += theme.fg("accent", args.action || "?");
299
- const target = args.target || args.pane || args.tab || args.workspace;
300
- if (target) text += theme.fg("muted", ` ${target}`);
301
- if (args.name) text += theme.fg("muted", ` ${args.name}`);
302
- if (args.kind) text += theme.fg("dim", ` › ${args.kind}`);
443
+ const target = args.target || args.pane || args.kind || args.workspace;
444
+ if (target) text += theme.fg("accent", target);
445
+ if (args.label) text += theme.fg("muted", ` ${args.label}`);
446
+ if (args.scope) text += theme.fg("dim", ` › ${args.scope}`);
447
+ if (args.mode) text += theme.fg("dim", ` › ${args.mode}`);
303
448
  if (args.direction) text += theme.fg("dim", ` › ${args.direction}`);
304
449
  if (args.command) text += theme.fg("dim", ` › ${args.command}`);
305
450
  if (args.prompt) text += theme.fg("dim", ` › ${args.prompt}`);
306
- if (args.match) text += theme.fg("dim", ` › ${args.match}`);
451
+ if (args.dryRun === false) text += theme.fg("warning", " › close");
307
452
  component.setText(text);
308
453
  return component;
309
454
  }
310
455
 
311
- function renderToolResult(result: any, options: { expanded: boolean; isPartial: boolean }, theme: any) {
312
- if (options.isPartial) return new Text(theme.fg("warning", "◌ waiting"), 0, 0);
313
- const details = result.details as Record<string, any> | undefined;
456
+ function renderVerbResult(result: any, options: { expanded: boolean; isPartial: boolean }, theme: any) {
457
+ if (options.isPartial) return new Text(theme.fg("warning", "◌ running"), 0, 0);
314
458
  const content = result.content?.[0];
315
459
  const rawText = content?.type === "text" ? content.text : "";
460
+ const details = result.details as Envelope | undefined;
316
461
  if (!details) return new Text(rawText, 0, 0);
317
462
 
318
- if (details.agent) {
319
- const agent = details.agent as AgentInfo;
320
- return new Text(
321
- `${statusDot(theme, agent.agent_status)} ${theme.fg("accent", agentDisplayName(agent))} ${theme.fg("dim", agent.agent_status)}`,
322
- 0,
323
- 0,
324
- );
325
- }
326
- if (Array.isArray(details.agents)) {
327
- const agents = details.agents as AgentInfo[];
328
- return new Text(
329
- agents.length
330
- ? agents
331
- .map(
332
- (agent) =>
333
- `${statusDot(theme, agent.agent_status)} ${theme.fg(agent.focused ? "accent" : "muted", agentDisplayName(agent))} ${theme.fg("dim", agent.agent_status)}`,
334
- )
335
- .join("\n")
336
- : theme.fg("dim", "no agents"),
337
- 0,
338
- 0,
339
- );
340
- }
341
- if (details.read) {
342
- let text = theme.fg("accent", `▤ ${details.target || details.pane}`);
343
- if (options.expanded && rawText) text += `\n${rawText.split("\n").slice(0, 40).map((line: string) => theme.fg("dim", line)).join("\n")}`;
344
- return new Text(text, 0, 0);
345
- }
346
- return new Text(theme.fg("success", `✓ ${details.action || "done"}`), 0, 0);
463
+ const mark = details.ok
464
+ ? theme.fg("success", "✓")
465
+ : details.exitCode === -1
466
+ ? theme.fg("warning", "◷")
467
+ : theme.fg("error", "✗");
468
+ const code = details.exitCode === 0 ? "" : theme.fg("dim", ` exit=${details.exitCode}`);
469
+ const lines = rawText.split("\n").filter((line: string) => line.trim() !== "");
470
+ const head = `${mark}${code} ${theme.fg("muted", lines[0] ?? "")}`;
471
+ if (!options.expanded) return new Text(head, 0, 0);
472
+ return new Text([head, ...lines.slice(1).map((line: string) => theme.fg("dim", line))].join("\n"), 0, 0);
347
473
  }
348
474
 
349
475
  export default function (pi: ExtensionAPI) {
350
476
  if (process.env.HERDR_ENV !== "1" || !process.env.HERDR_PANE_ID) return;
351
477
 
352
- /**
353
- * 本进程最近一次下发到某个 pane 的文本(`run` 的命令、`send_text` 的字面量)。
354
- * wait_output 用它识别“命中行其实就是刚敲下去的命令回显”,而不是命令产生的输出。
355
- */
356
- const lastSubmitted = new Map<string, string>();
357
-
358
478
  async function execHerdr(args: string[], signal?: AbortSignal) {
359
479
  const result = await pi.exec("herdr", args, { signal });
360
480
  if (signal?.aborted || result.killed) throw new Error("Aborted");
361
481
  if (result.code !== 0) {
362
482
  // herdr 把结构化错误写在 stderr(退出码 1)。带上 code,调用方才能区分
363
- // timeout / agent_prompt_stalled 这类“没观测到”和真正的失败。
483
+ // timeout / agent_prompt_stalled 这类「没观测到」和真正的失败。
364
484
  const detail = parseHerdrError(result.stderr) || parseHerdrError(result.stdout);
365
485
  throw herdrError(
366
486
  detail?.message || `herdr ${args.join(" ")} failed with exit code ${result.code}`,
@@ -390,24 +510,11 @@ export default function (pi: ExtensionAPI) {
390
510
  return (await execHerdr(args, signal)).stdout;
391
511
  }
392
512
 
393
- /**
394
- * 读一次 pane 快照。失败时返回 null —— 读快照失败不能让 wait_output 本身失败。
395
- */
396
- async function readPaneSnapshot(
397
- paneId: string,
398
- source: WaitOutputSource,
399
- lines: number | undefined,
400
- signal?: AbortSignal,
401
- ): Promise<string | null> {
402
- const args = ["pane", "read", paneId, "--source", source];
403
- if (lines != null) args.push("--lines", String(lines));
404
- try {
405
- return await execHerdrText(args, signal);
406
- } catch (error) {
407
- if (isAbortError(error, signal)) throw error;
408
- return null;
409
- }
410
- }
513
+ const textCall = (args: string[], signal?: AbortSignal, timeoutMs?: number) =>
514
+ attempt(signal, timeoutMs ?? DEFAULT_LOCAL_TIMEOUT, (s) => execHerdrText(args, s));
515
+
516
+ const jsonCall = <T>(args: string[], signal?: AbortSignal, timeoutMs?: number) =>
517
+ attempt(signal, timeoutMs ?? DEFAULT_LOCAL_TIMEOUT, (s) => execHerdrJson<T>(args, s));
411
518
 
412
519
  async function getCurrentPane(signal?: AbortSignal): Promise<PaneInfo> {
413
520
  const response = await execHerdrJson<{ result: { pane: PaneInfo } }>(["pane", "current", "--current"], signal);
@@ -427,560 +534,554 @@ export default function (pi: ExtensionAPI) {
427
534
  return response.result.layout;
428
535
  }
429
536
 
537
+ /**
538
+ * 把 agent target 解析成 `pane run` 回退需要的完整 pane id。agent 名不是 pane id,
539
+ * 侧栏短名也不是,猜一下就会把 prompt 打进错的终端。`agent get` 对活着的名字和承载它
540
+ * 的 pane id 都答;全机列表是第二索引,因为自报 agent 可能查不到直接的那一条。
541
+ */
542
+ async function resolveAgentPaneId(target: string, signal?: AbortSignal): Promise<string> {
543
+ try {
544
+ const response = await execHerdrJson<{
545
+ result?: { pane?: { pane_id?: string }; agent?: { pane_id?: string } };
546
+ }>(["agent", "get", target], signal);
547
+ const paneId = response.result?.pane?.pane_id || response.result?.agent?.pane_id;
548
+ if (paneId) return paneId;
549
+ } catch (error) {
550
+ if (isAbortError(error, signal)) throw error;
551
+ // 落到下面的列表扫描:它才是权威索引。
552
+ }
553
+ const response = await execHerdrJson<{ result: { agents: AgentInfo[] } }>(["agent", "list"], signal);
554
+ const match = (response.result.agents || []).find(
555
+ (agent) => agent.pane_id === target || agent.name === target || agentDisplayName(agent) === target,
556
+ );
557
+ if (match?.pane_id) return match.pane_id;
558
+ throw new Error(
559
+ `'${target}' 没有解析成完整 pane id(agent get 与 agent list 都查不到),` +
560
+ "无法确定该往哪个 pane 键入 —— 不猜。先用 herdr_overview 拿到完整 pane id 再发。",
561
+ );
562
+ }
563
+
430
564
  pi.registerTool({
431
- name: "herdr_layout",
432
- label: "Herdr Layout",
565
+ name: "herdr_overview",
566
+ label: "Herdr Overview",
567
+ outputSchema: envelopeOutputSchema,
433
568
  description:
434
- "Create and inspect Herdr terminal topology. Workspaces contain tabs; tabs contain panes. Creating a workspace or tab also creates a root pane, while splitting creates another pane. Layout actions never start an agent or ordinary command. Read pane IDs from results and pass them to herdr_pane or herdr_agent. Creation defaults to the caller's cwd and preserves UI focus. pane_split defaults to the caller's pane and chooses right or down from its geometry.",
435
- promptSnippet: "Inspect or create Herdr workspaces, tabs, and pane topology",
569
+ "盘点:一次拿到自己的位置 + 全机 workspace 拓扑 + 全机 agent 状态(名称/状态/cwd/pane id)。管理其他 agent 的第一步几乎总是它 —— 先看有哪些 agent、谁在忙、谁空着。只读。",
570
+ promptSnippet: "一次看清 herdr 里有哪些 agent、谁在忙、各自在哪个 pane",
436
571
  promptGuidelines: [
437
- "Use herdr_layout, herdr_pane, and herdr_agent only when the user explicitly mentions Herdr or asks to inspect or control Herdr.",
438
- "Use herdr_layout to create terminal topology before starting a process or agent. Default to a sibling pane in the caller's current tab and cwd; create a tab or workspace only when requested.",
439
- "Read opaque workspace, tab, and pane IDs from herdr_layout results instead of constructing them, and preserve UI focus unless the user asks to switch context.",
572
+ "要管别的 agent 之前先调 herdr_overview,用结果里的完整 pane id 当后续 target,不要自己拼。",
573
+ "列表被 limit 截断时会写明隐藏条数 —— 别把截断结果当成「全机就这些」。",
574
+ "herdr_overview 只读:不切焦点、不下发任何东西。",
440
575
  ],
441
576
  parameters: Type.Object({
442
- action: StringEnum(
443
- [
444
- "current",
445
- "workspace_list",
446
- "workspace_create",
447
- "workspace_focus",
448
- "tab_list",
449
- "tab_create",
450
- "tab_focus",
451
- "pane_list",
452
- "pane_layout",
453
- "pane_split",
454
- ] as const,
455
- { description: "Layout action" },
577
+ limit: Type.Optional(Type.Integer({ description: `每个列表最多返回多少条,默认 ${OVERVIEW_LIST_LIMIT}` })),
578
+ workspace: Type.Optional(
579
+ Type.String({ description: "只看某个 workspace 的拓扑(如 wC)。agent 列表也一并收窄到该 workspace" }),
456
580
  ),
457
- workspace: Type.Optional(Type.String({ description: "Opaque workspace ID" })),
458
- tab: Type.Optional(Type.String({ description: "Opaque tab ID" })),
459
- pane: Type.Optional(
460
- Type.String({ description: "Opaque source pane ID. Omit for current, pane_layout, or pane_split to use the caller's pane." }),
461
- ),
462
- label: Type.Optional(Type.String({ description: "Label for a new workspace or tab" })),
463
- direction: Type.Optional(DirectionEnum),
464
- cwd: Type.Optional(Type.String({ description: "Working directory. Defaults to the caller pane's foreground cwd." })),
465
- focus: Type.Optional(Type.Boolean({ description: "Change UI focus after creation. Defaults to false." })),
466
581
  }),
467
- async execute(_toolCallId, params, signal, _onUpdate, _ctx) {
468
- switch (params.action) {
469
- case "current": {
470
- const pane = await getCurrentPane(signal);
471
- return {
472
- content: [{ type: "text", text: summarizePane(pane, pane.pane_id) }],
473
- details: { action: "current", pane },
474
- };
475
- }
476
- case "workspace_list": {
477
- const response = await execHerdrJson<{ result: { workspaces: WorkspaceInfo[] } }>(
478
- ["workspace", "list"],
479
- signal,
480
- );
481
- const workspaces = response.result.workspaces || [];
482
- return {
483
- content: [{ type: "text", text: workspaces.length ? workspaces.map(summarizeWorkspace).join("\n") : "No workspaces." }],
484
- details: { action: "workspace_list", workspaces },
485
- };
486
- }
487
- case "workspace_create": {
488
- const current = await getCurrentPane(signal);
489
- const args = ["workspace", "create", "--cwd", params.cwd || current.foreground_cwd || current.cwd || process.cwd()];
490
- if (params.label) args.push("--label", params.label);
491
- args.push(params.focus === true ? "--focus" : "--no-focus");
492
- const response = await execHerdrJson<{
493
- result: { workspace: WorkspaceInfo; tab: TabInfo; root_pane: PaneInfo };
494
- }>(args, signal);
495
- const { workspace, tab, root_pane: rootPane } = response.result;
496
- return {
497
- content: [{ type: "text", text: `Created workspace ${workspace.workspace_id}, tab ${tab.tab_id}, root pane ${rootPane.pane_id}` }],
498
- details: { action: "workspace_create", workspace, tab, pane: rootPane },
499
- };
500
- }
501
- case "workspace_focus": {
502
- if (!params.workspace) throw new Error("'workspace' is required for workspace_focus");
503
- const response = await execHerdrJson<{ result: { workspace: WorkspaceInfo } }>(
504
- ["workspace", "focus", params.workspace],
505
- signal,
506
- );
507
- return {
508
- content: [{ type: "text", text: `Focused workspace ${response.result.workspace.workspace_id}` }],
509
- details: { action: "workspace_focus", workspace: response.result.workspace },
510
- };
511
- }
512
- case "tab_list": {
513
- const args = ["tab", "list"];
514
- if (params.workspace) args.push("--workspace", params.workspace);
515
- const response = await execHerdrJson<{ result: { tabs: TabInfo[] } }>(args, signal);
516
- const tabs = response.result.tabs || [];
517
- return {
518
- content: [{ type: "text", text: tabs.length ? tabs.map(summarizeTab).join("\n") : "No tabs." }],
519
- details: { action: "tab_list", tabs },
520
- };
521
- }
522
- case "tab_create": {
523
- const current = await getCurrentPane(signal);
524
- const args = ["tab", "create", "--workspace", params.workspace || current.workspace_id];
525
- args.push("--cwd", params.cwd || current.foreground_cwd || current.cwd || process.cwd());
526
- if (params.label) args.push("--label", params.label);
527
- args.push(params.focus === true ? "--focus" : "--no-focus");
528
- const response = await execHerdrJson<{ result: { tab: TabInfo; root_pane: PaneInfo } }>(args, signal);
529
- const { tab, root_pane: rootPane } = response.result;
530
- return {
531
- content: [{ type: "text", text: `Created tab ${tab.tab_id}, root pane ${rootPane.pane_id}` }],
532
- details: { action: "tab_create", tab, pane: rootPane },
533
- };
534
- }
535
- case "tab_focus": {
536
- if (!params.tab) throw new Error("'tab' is required for tab_focus");
537
- const response = await execHerdrJson<{ result: { tab: TabInfo } }>(["tab", "focus", params.tab], signal);
538
- return {
539
- content: [{ type: "text", text: `Focused tab ${response.result.tab.tab_id}` }],
540
- details: { action: "tab_focus", tab: response.result.tab },
541
- };
542
- }
543
- case "pane_list": {
544
- const current = await getCurrentPane(signal);
545
- const workspaceId = params.workspace || current.workspace_id;
546
- const response = await execHerdrJson<{ result: { panes: PaneInfo[] } }>(
547
- ["pane", "list", "--workspace", workspaceId],
548
- signal,
549
- );
550
- const panes = response.result.panes || [];
551
- return {
552
- content: [{ type: "text", text: panes.length ? panes.map((pane) => summarizePane(pane, current.pane_id)).join("\n") : "No panes." }],
553
- details: { action: "pane_list", panes, workspaceId },
554
- };
555
- }
556
- case "pane_layout": {
557
- const paneId = params.pane || (await getCurrentPane(signal)).pane_id;
558
- const layout = await getPaneLayout(paneId, signal);
559
- return {
560
- content: [{ type: "text", text: JSON.stringify(layout, null, 2) }],
561
- details: { action: "pane_layout", layout },
562
- };
563
- }
564
- case "pane_split": {
565
- const current = await getCurrentPane(signal);
566
- const source = params.pane ? await getPane(params.pane, signal) : current;
567
- const direction = params.direction || chooseSplitDirection(await getPaneLayout(source.pane_id, signal), source.pane_id);
568
- const cwd = params.cwd || source.foreground_cwd || source.cwd || current.foreground_cwd || current.cwd || process.cwd();
569
- const args = ["pane", "split", source.pane_id, "--direction", direction, "--cwd", cwd];
570
- args.push(params.focus === true ? "--focus" : "--no-focus");
571
- const response = await execHerdrJson<{ result: { pane: PaneInfo } }>(args, signal);
572
- const pane = response.result.pane;
573
- return {
574
- content: [{ type: "text", text: `Created pane ${pane.pane_id} by splitting ${source.pane_id} ${direction}` }],
575
- details: { action: "pane_split", pane, sourcePaneId: source.pane_id, direction },
576
- };
577
- }
582
+ async execute(_toolCallId, params, signal) {
583
+ const limit = params.limit ?? OVERVIEW_LIST_LIMIT;
584
+ // dsh 的参数 spec 没有 minimum,所以这条校验落在 body 里;pi 保持一致好让信封统一。
585
+ if (!Number.isInteger(limit) || limit < 1) {
586
+ return envelopeResult(precondition(`--limit must be >= 1 (default ${OVERVIEW_LIST_LIMIT}).`));
587
+ }
588
+ // 三次独立读,并行付一次 RTT 而不是三次。
589
+ const [current, workspaces, agents] = await Promise.all([
590
+ jsonCall<{ result: { pane: PaneInfo } }>(["pane", "current", "--current"], signal),
591
+ jsonCall<{ result: { workspaces: WorkspaceInfo[] } }>(["workspace", "list"], signal),
592
+ jsonCall<{ result: { agents: AgentInfo[] } }>(["agent", "list"], signal),
593
+ ]);
594
+ const failed: Failure | undefined = [current, workspaces, agents].find(
595
+ (part): part is Failure => part.ok !== true,
596
+ );
597
+ const allWorkspaces = workspaces.ok === true ? workspaces.value.result.workspaces || [] : [];
598
+ const allAgents = agents.ok === true ? agents.value.result.agents || [] : [];
599
+ // `agent list` 不接受 --workspace,但每条 agent 记录都带 workspace_id,
600
+ // 所以收窄是精确的客户端过滤,不需要第二次查询。
601
+ const scopedWorkspaces = params.workspace
602
+ ? allWorkspaces.filter((workspace) => workspace.workspace_id === params.workspace)
603
+ : allWorkspaces;
604
+ const scopedAgents = params.workspace
605
+ ? allAgents.filter((agent) => agent.workspace_id === params.workspace)
606
+ : allAgents;
607
+ const output = [
608
+ "## 我所在的位置",
609
+ current.ok === true
610
+ ? summarizePane(current.value.result.pane, current.value.result.pane.pane_id)
611
+ : failureEnvelope(current).output,
612
+ "## workspace 拓扑",
613
+ workspaces.ok === true
614
+ ? capList(scopedWorkspaces, limit, "workspaces", summarizeWorkspace)
615
+ : failureEnvelope(workspaces).output,
616
+ params.workspace ? `## agent(workspace ${params.workspace})` : "## 全机 agent",
617
+ agents.ok === true
618
+ ? capList(scopedAgents, limit, "agents", summarizeAgent)
619
+ : failureEnvelope(agents).output,
620
+ ].join("\n");
621
+ // 本地超时不是 herdr 错误,但确实少了一段;把盘点报成 ok 会让 manager
622
+ // 以为自己看全了整支舰队。
623
+ if (failed === undefined) return envelopeResult({ ok: true, exitCode: 0, output });
624
+ if (failed.timedOut) {
625
+ return envelopeResult({
626
+ ok: false,
627
+ exitCode: -1,
628
+ timedOut: true,
629
+ output,
630
+ note: "盘点有部分超时:本地等待超时只说明没观测到,不等于 herdr 出错。重跑一次,或缩小 limit 再试。",
631
+ });
578
632
  }
633
+ return envelopeResult({ ok: false, exitCode: 1, output, note: EXIT_NOTE[1] });
579
634
  },
580
635
  renderCall(args, theme, context) {
581
- return renderToolCall("herdr_layout", args, theme, context);
636
+ return renderVerbCall("herdr_overview", args, theme, context);
582
637
  },
583
638
  renderResult(result, options, theme) {
584
- return renderToolResult(result, options, theme);
639
+ return renderVerbResult(result, options, theme);
585
640
  },
586
641
  });
587
642
 
588
643
  pi.registerTool({
589
- name: "herdr_pane",
590
- label: "Herdr Pane",
644
+ name: "herdr_read",
645
+ label: "Herdr Read",
646
+ outputSchema: envelopeOutputSchema,
591
647
  description:
592
- "Control a raw Herdr terminal pane. Use for shells, tests, servers, builds, logs, and other ordinary processes: run a command, read output, wait for matching output, send literal text or terminal keys, inspect, or close. Pane actions target opaque pane IDs and do not validate agent identity or interpret agent lifecycle. Use herdr_agent instead when controlling a recognized coding agent. Read output is truncated to 2000 lines or 50KB.",
593
- promptSnippet: "Run and inspect ordinary commands in Herdr terminal panes",
648
+ "读屏:看某个 pane(或它里面的 agent)当前输出。盯梢一个 agent 在干什么、卡在哪,用它。target 用完整限定 pane id(如 wC:p77);scope=agent 走 agent 语义视图,pane 走裸终端视图。只读。默认 digest 模式:只回「前段命中的审批/报错行 + 尾部 30 行」,省上下文;要看整段传 mode=\"raw\"。",
649
+ promptSnippet: "读取某个 pane 或 agent 的当前输出(默认压缩成信号行 + 尾部)",
594
650
  promptGuidelines: [
595
- "Use herdr_pane for ordinary commands and raw terminal control; use herdr_agent for coding-agent prompts, lifecycle waits, reads, and interactive keys.",
596
- "Use herdr_pane wait_output for tests, servers, builds, and watchers. It searches existing output immediately; use recent-unwrapped for logs and transcripts.",
597
- "wait_output reports a timeout as matched:false with the pane tail instead of raising an error: a timeout means the pattern never appeared, not that the command failed.",
598
- "Pick a wait_output pattern that only appears in the command output. Patterns that also occur in the command you sent (script names, function names, marker constants) match the echoed input line and can report success before the command produces anything; echoedInput flags such a hit.",
599
- "Never use a shell prompt (❯, $, \\$) as a wait_output pattern; it is always present and therefore meaningless.",
600
- "Do not close a Herdr pane you did not create unless the user explicitly asks. herdr_pane always refuses to close the pane running the current pi process.",
651
+ "读屏先走默认 scope=agent;pane 里没有 herdr 可识别的 agent 时会自动回退到 pane 视图,via 会说明走了哪条。",
652
+ "默认 digest 已经够判断「在干什么/卡在哪」;确信需要逐行引用原文时才传 mode=\"raw\"。",
653
+ "失败不会被压缩:错误原文本身就是证据。",
601
654
  ],
602
655
  parameters: Type.Object({
603
- action: StringEnum(["get", "run", "read", "wait_output", "send_text", "send_keys", "close"] as const, {
604
- description: "Raw pane action",
605
- }),
606
- pane: Type.String({ description: "Opaque pane ID returned by herdr_layout" }),
607
- command: Type.Optional(Type.String({ description: "Shell command to submit atomically with Enter for run" })),
608
- text: Type.Optional(Type.String({ description: "Literal text to send without Enter for send_text" })),
609
- keys: Type.Optional(
610
- Type.Array(Type.String(), { description: "Logical terminal keys for send_keys, such as esc, enter, up, or ctrl+c" }),
611
- ),
612
- match: Type.Optional(
613
- Type.String({
614
- description:
615
- "Pattern for wait_output. Patterns containing regex metacharacters (| ( ) [ ] { } ^ $ * + ? \\) are treated as Rust regular expressions unless regex is explicitly false",
656
+ target: Type.String({ description: "完整限定 pane id(w1B:pF),或唯一 agent 名" }),
657
+ scope: Type.Optional(
658
+ StringEnum(["agent", "pane"] as const, {
659
+ description: "默认 agent;pane 里没有可识别 agent 时用 pane",
616
660
  }),
617
661
  ),
618
- regex: Type.Optional(
619
- Type.Boolean({ description: "Force match to be a literal string (false) or a Rust regular expression (true)" }),
620
- ),
621
662
  source: Type.Optional(ReadSourceEnum),
622
- lines: Type.Optional(Type.Integer({ minimum: 1, description: "Rendered terminal rows to read or search" })),
623
- format: Type.Optional(OutputFormatEnum),
624
- raw: Type.Optional(
625
- Type.Boolean({ description: "Keep ANSI escapes while reading (read) or matching (wait_output)" }),
663
+ lines: Type.Optional(Type.Integer({ minimum: 1, description: "只取尾部 N 行(默认 2000 行 / 50KB 封顶)" })),
664
+ mode: Type.Optional(ReadModeEnum),
665
+ tail: Type.Optional(Type.Integer({ minimum: 1, description: `digest 保留多少行尾部,默认 ${READ_TAIL_LINES}` })),
666
+ }),
667
+ async execute(_toolCallId, params, signal) {
668
+ const scope = params.scope ?? "agent";
669
+ const source = params.source ?? "recent-unwrapped";
670
+ const lines = params.lines === undefined ? [] : ["--lines", String(params.lines)];
671
+ const build = (view: "agent" | "pane") =>
672
+ view === "agent"
673
+ ? ["agent", "read", params.target, "--source", source, ...lines]
674
+ : ["pane", "read", params.target, "--source", source, ...lines];
675
+ const first = await textCall(build(scope), signal);
676
+ let result: Attempt<string> = first;
677
+ let via: string = scope;
678
+ if (first.ok !== true && scope === "agent") {
679
+ // 这个 pane 里没有 herdr 认的 agent:回退到裸终端视图,并说清哪条路给出了文字。
680
+ result = await textCall(build("pane"), signal);
681
+ via = `pane(agent 视图不可用:${failureLine(first)})`;
682
+ }
683
+ const mode = params.mode ?? READ_MODE_DEFAULT;
684
+ // 失败从不压缩:错误文本本身就是证据。
685
+ if (result.ok !== true) return envelopeResult(failureEnvelope(result, { via }));
686
+ const output = formatOutput(result.value).trim();
687
+ if (mode === "raw") return envelopeResult({ ok: true, exitCode: 0, output, via });
688
+ return envelopeResult({
689
+ ok: true,
690
+ exitCode: 0,
691
+ output: digestRead(output, params.tail ?? READ_TAIL_LINES),
692
+ via: `${via} · digest`,
693
+ });
694
+ },
695
+ renderCall(args, theme, context) {
696
+ return renderVerbCall("herdr_read", args, theme, context);
697
+ },
698
+ renderResult(result, options, theme) {
699
+ return renderVerbResult(result, options, theme);
700
+ },
701
+ });
702
+
703
+ pi.registerTool({
704
+ name: "herdr_wait",
705
+ label: "Herdr Wait",
706
+ outputSchema: envelopeOutputSchema,
707
+ description:
708
+ "等某个 agent 进入指定状态(默认 idle/done/blocked)。盯梢的关键原语:下发任务后等它落定。注意超时只表示「没观测到」,不是失败 —— 不要据此重发任务,先 herdr_read 看实际情况。",
709
+ promptSnippet: "等待某个 agent 进入 idle/done/blocked",
710
+ promptGuidelines: [
711
+ "下发任务以后用 herdr_wait 等它落定,而不是反复 herdr_read 轮询。",
712
+ "等待超时只说明给定时间内没观测到那个状态,不等于对方失败,也不等于任务没送到。",
713
+ ],
714
+ parameters: Type.Object({
715
+ target: Type.String({ description: "完整限定 pane id,或唯一 agent 名" }),
716
+ until: Type.Optional(Type.Array(StatusEnum, { description: "要等的状态,默认 idle/done/blocked" })),
717
+ timeoutMs: Type.Optional(Type.Integer({ minimum: 1, description: "herdr 侧等待上限,默认由 herdr 决定" })),
718
+ }),
719
+ async execute(_toolCallId, params, signal) {
720
+ const until = (params.until ?? []).flatMap((state) => ["--until", state]);
721
+ const timeout = params.timeoutMs === undefined ? [] : ["--timeout", String(params.timeoutMs)];
722
+ const local = (params.timeoutMs ?? WAIT_DEFAULT_TIMEOUT) + WAIT_LOCAL_MARGIN;
723
+ const result = await jsonCall<{ result: { agent: AgentInfo } }>(
724
+ ["agent", "wait", params.target, ...until, ...timeout],
725
+ signal,
726
+ local,
727
+ );
728
+ if (result.ok === true) {
729
+ return envelopeResult({
730
+ ok: true,
731
+ exitCode: 0,
732
+ output: `Agent settled: ${summarizeAgent(result.value.result.agent)}`,
733
+ });
734
+ }
735
+ if (result.timedOut) return envelopeResult(failureEnvelope(result));
736
+ if (!isHerdrTimeout(result.error)) return envelopeResult(failureEnvelope(result));
737
+ // herdr 把等待超时也报成结构化错误(非零退出)。它的意思是「没观测到」,
738
+ // 所以保留失败退出码,但把这个「不意味着什么」说清楚。
739
+ return envelopeResult({
740
+ ok: false,
741
+ exitCode: 1,
742
+ output: failureEnvelope(result).output,
743
+ note:
744
+ "等待超时:给定时间内没观测到目标状态。这不等于对方失败、也不等于任务没送到 —— " +
745
+ "先 herdr_read 看它实际在做什么,再决定要不要重发。",
746
+ });
747
+ },
748
+ renderCall(args, theme, context) {
749
+ return renderVerbCall("herdr_wait", args, theme, context);
750
+ },
751
+ renderResult(result, options, theme) {
752
+ return renderVerbResult(result, options, theme);
753
+ },
754
+ });
755
+
756
+ pi.registerTool({
757
+ name: "herdr_prompt",
758
+ label: "Herdr Prompt",
759
+ outputSchema: envelopeOutputSchema,
760
+ description:
761
+ "给另一个 agent 下发一段 prompt(调度)。先试 agent prompt(语义路径);若对方是自报状态的 dsh-tui,herdr 会回 agent_not_ready,此时自动回退成 pane run 直接键入。返回的 via 会说明走了哪条路。「已送出」不等于「已开始干活」—— 要确认请配 herdr_wait 或 herdr_read。",
762
+ promptSnippet: "给另一个 agent 下发任务(自动回退到直接键入)",
763
+ promptGuidelines: [
764
+ "herdr_prompt 默认发完就走(不等落定);要等它进入稳定状态请传 wait=true,或另外调 herdr_wait。",
765
+ "目标正在 working 时默认拒绝下发(那是插队,会打断它手上的任务):先 herdr_wait 等它落定;确实要插队才传 force=true。",
766
+ "如果失败信封说 agent_blocked,说明 herdr 在发送任何输入之前就拒绝了:先 herdr_read 看清弹窗内容并问用户,不要重复下发。",
767
+ "「已送出」不等于「已开始干活」—— 用 herdr_wait / herdr_read 确认。",
768
+ ],
769
+ parameters: Type.Object({
770
+ target: Type.String({ description: "完整限定 pane id,或 herdr 命名并激活的 agent 名" }),
771
+ prompt: Type.String({ description: "要下发的完整任务文本" }),
772
+ wait: Type.Optional(Type.Boolean({ description: "true 时等它进入稳定状态(agent prompt 语义);默认发了就走" })),
773
+ force: Type.Optional(
774
+ Type.Boolean({ description: "目标正在 working 时,默认拒绝下发(插队会打断它);确实要插队传 true" }),
626
775
  ),
627
- timeout: Type.Optional(Type.Integer({ minimum: 1, description: "Wait timeout in milliseconds; omitted means indefinite" })),
628
776
  }),
629
- async execute(_toolCallId, params, signal, onUpdate, _ctx) {
630
- switch (params.action) {
631
- case "get": {
632
- const pane = await getPane(params.pane, signal);
633
- return {
634
- content: [{ type: "text", text: summarizePane(pane) }],
635
- details: { action: "get", pane },
636
- };
637
- }
638
- case "run": {
639
- if (!params.command) throw new Error("'command' is required for run");
640
- // pane run/send-text/send-keys report success with empty stdout, so only the exit code is meaningful.
641
- await execHerdr(["pane", "run", params.pane, params.command], signal);
642
- lastSubmitted.set(params.pane, params.command);
643
- return {
644
- content: [{ type: "text", text: `Submitted command to pane ${params.pane}` }],
645
- details: { action: "run", pane: params.pane, command: params.command },
646
- };
647
- }
648
- case "read": {
649
- const args = ["pane", "read", params.pane, "--source", params.source || "recent-unwrapped"];
650
- if (params.lines != null) args.push("--lines", String(params.lines));
651
- if (params.format) args.push("--format", params.format);
652
- if (params.raw) args.push("--raw");
653
- const output = await execHerdrText(args, signal);
654
- return {
655
- content: [{ type: "text", text: formatOutput(output) }],
656
- details: { action: "read", pane: params.pane, read: true, source: params.source || "recent-unwrapped" },
657
- };
658
- }
659
- case "wait_output": {
660
- if (!params.match) throw new Error("'match' is required for wait_output");
661
- if (params.source === "detection") throw new Error("wait_output does not support the detection source; use read");
662
- const startedAt = Date.now();
663
- const source: WaitOutputSource = (params.source as WaitOutputSource) || "recent-unwrapped";
664
- // 未显式指定 regex 时,含正则元字符的 pattern 按正则解释:字面量语义下
665
- // `a|b` / `log \$` 永远不可能命中,只会白等满 timeout。
666
- const matchMode: "literal" | "regex" =
667
- params.regex === true
668
- ? "regex"
669
- : params.regex === false
670
- ? "literal"
671
- : REGEX_METACHARS.test(params.match)
672
- ? "regex"
673
- : "literal";
674
- const notes: string[] = [];
675
- if (matchMode === "literal" && REGEX_METACHARS.test(params.match)) {
676
- notes.push(
677
- `注意:${JSON.stringify(params.match)} 含正则元字符但按字面量匹配,很可能永远无法命中;需要正则请设 regex: true。`,
678
- );
679
- }
680
- onUpdate?.({
681
- content: [{ type: "text", text: `Waiting for output in ${params.pane}...` }],
682
- details: { action: "wait_output", pane: params.pane, waiting: true },
683
- });
684
- const args = ["pane", "wait-output", params.pane, matchMode === "regex" ? "--regex" : "--match", params.match];
685
- args.push("--source", source);
686
- if (params.lines != null) args.push("--lines", String(params.lines));
687
- if (params.timeout != null) args.push("--timeout", String(params.timeout));
688
- if (params.raw) args.push("--raw");
689
- let response: { result: WaitOutputResult } | null;
690
- try {
691
- response = await execHerdrJson<{ result: WaitOutputResult }>(args, signal);
692
- } catch (error) {
693
- if (!isHerdrTimeout(error)) throw error;
694
- response = null;
695
- }
696
- if (response === null) {
697
- // 超时是“没观测到”,不是失败:返回观测事实(含 pane 尾部输出)让调用方自己判断。
698
- const tail = await readPaneSnapshot(params.pane, source, params.lines, signal);
699
- const waitedMs = Date.now() - startedAt;
700
- return {
701
- content: [
702
- {
703
- type: "text",
704
- text: [
705
- `未匹配:${JSON.stringify(params.match)}(模式=${matchMode}, source=${source})在 ${waitedMs}ms 内未出现。`,
706
- "这既不表示命令失败,也不表示命令完成,只表示这个 pattern 没有出现。",
707
- ...notes,
708
- tail ? `当前 pane 尾部输出:\n${formatOutput(tail)}` : "(未能读取 pane 尾部输出)",
709
- ].join("\n"),
710
- },
711
- ],
712
- details: {
713
- action: "wait_output",
714
- pane: params.pane,
715
- matched: false,
716
- waitedMs,
717
- matchMode,
718
- source,
719
- tail,
720
- },
721
- };
722
- }
723
- const matched = response.result;
724
- const output = matched.read?.text || matched.matched_line;
725
- // 命中行可能来自刚下发的命令回显(`❯ <命令>`)而不是命令产生的输出,
726
- // 回显在命令还没跑完时就已经存在。本进程自己记录的下发内容是最可靠的事实依据。
727
- const submitted = lastSubmitted.get(params.pane);
728
- const echoedInput = !!submitted && matched.matched_line.includes(submitted);
729
- if (echoedInput) {
730
- notes.push(
731
- "注意:命中的是刚下发的命令回显行(❯ 后面那段),不是命令产生的输出;命令可能还没跑完。要等真正的输出,请改用只会出现在输出里的 pattern。",
732
- );
733
- }
734
- return {
735
- content: [
736
- {
737
- type: "text",
738
- text: [...notes, `Matched: ${matched.matched_line}`, "", formatOutput(output)].join("\n"),
739
- },
740
- ],
741
- details: {
742
- action: "wait_output",
743
- pane: params.pane,
744
- matched: true,
745
- matchedLine: matched.matched_line,
746
- matchMode,
747
- source,
748
- echoedInput,
749
- elapsedMs: Date.now() - startedAt,
750
- },
751
- };
752
- }
753
- case "send_text": {
754
- if (!params.text) throw new Error("'text' is required for send_text");
755
- await execHerdr(["pane", "send-text", params.pane, params.text], signal);
756
- lastSubmitted.set(params.pane, params.text);
757
- return {
758
- content: [{ type: "text", text: `Sent literal text to pane ${params.pane}` }],
759
- details: { action: "send_text", pane: params.pane },
760
- };
761
- }
762
- case "send_keys": {
763
- if (!params.keys?.length) throw new Error("'keys' is required for send_keys");
764
- await execHerdr(["pane", "send-keys", params.pane, ...params.keys], signal);
765
- return {
766
- content: [{ type: "text", text: `Sent ${params.keys.join(" ")} to pane ${params.pane}` }],
767
- details: { action: "send_keys", pane: params.pane, keys: params.keys },
768
- };
769
- }
770
- case "close": {
771
- const current = await getCurrentPane(signal);
772
- if (params.pane === current.pane_id) throw new Error("Refusing to close the pane pi is running in.");
773
- await execHerdrJson(["pane", "close", params.pane], signal);
774
- return {
775
- content: [{ type: "text", text: `Closed pane ${params.pane}` }],
776
- details: { action: "close", pane: params.pane },
777
- };
778
- }
777
+ async execute(_toolCallId, params, signal) {
778
+ const self = process.env.HERDR_PANE_ID;
779
+ // 先探一下:状态决定插队保护,pane id 让自身检查能抓到「名字与自己的 pane 不同」的目标。
780
+ const probe = await jsonCall<{ result: { agent: AgentInfo } }>(["agent", "get", params.target], signal);
781
+ const agent = probe.ok === true ? probe.value.result.agent : undefined;
782
+ const pane = agent?.pane_id;
783
+ const status = agent?.agent_status;
784
+ const unresolved =
785
+ probe.ok === true && pane !== undefined
786
+ ? undefined
787
+ : "无法把 target 解析成完整 pane id(agent get 失败或输出未识别),下面的回退将不可用。";
788
+ // 往自己的 pane 里键入等于给自己喂输入,会打断正在进行的这一轮。
789
+ if (self !== undefined && (params.target === self || pane === self)) return envelopeResult(refusal(SELF_PANE_REFUSAL));
790
+ // 插队正在 working 的 agent:managing-agents 的纪律,这里做成机械约束。
791
+ if (PROMPT_GUARD_ON && status === "working" && params.force !== true) {
792
+ return envelopeResult({
793
+ ok: false,
794
+ exitCode: 2,
795
+ output: `目标 ${pane} 当前状态:working。`,
796
+ note:
797
+ "它正在干活,现在下发等于插队,会打断它手上的任务。先 herdr_wait 等它落定再发;" +
798
+ "确实要插队就显式传 force=true。",
799
+ });
800
+ }
801
+ // herdr CLI 只有 --wait,没有 --no-wait。
802
+ const waitFlag = params.wait === true ? ["--wait"] : [];
803
+ const semantic = await textCall(["agent", "prompt", params.target, params.prompt, ...waitFlag], signal);
804
+ if (semantic.ok === true) {
805
+ return envelopeResult({
806
+ ok: true,
807
+ exitCode: 0,
808
+ output: semantic.value.trim(),
809
+ via: "agent prompt",
810
+ ...(unresolved === undefined ? {} : { note: unresolved }),
811
+ });
812
+ }
813
+ // 本地超时:不知道有没有送达,但也不是 herdr 拒绝。
814
+ if (semantic.timedOut) return envelopeResult(failureEnvelope(semantic, { via: "agent prompt" }));
815
+ // 目标卡在审批/提问弹窗:herdr 在发送任何输入之前就拒绝了,确定没下发。
816
+ // 这与 stalled 相反 —— 重试必然同样失败,必须先看清界面。
817
+ if (isPromptBlocked(semantic.error)) {
818
+ return envelopeResult({
819
+ ok: false,
820
+ exitCode: 2,
821
+ output: errorMessage(semantic.error),
822
+ via: "agent prompt",
823
+ note:
824
+ `${params.target} 处于 blocked(等待审批或回答),herdr 在发送任何输入前就拒绝了本次 prompt。` +
825
+ "先用 herdr_read 看清阻塞的界面,向用户确认怎么答,再用 herdr_prompt / herdr_run 回应;不要重复下发。",
826
+ });
779
827
  }
828
+ // submission accepted 之后没观测到 working/blocked:prompt 已经下发了,
829
+ // 这是启发式,不能当成下发失败。
830
+ if (isPromptStalled(semantic.error)) {
831
+ const observed = /status is (\w+)/.exec(errorMessage(semantic.error))?.[1];
832
+ return envelopeResult({
833
+ ok: true,
834
+ exitCode: 0,
835
+ output:
836
+ `Prompt 已下发到 ${params.target}(submission accepted),但 5s 内未观测到进入 working/blocked` +
837
+ (observed ? `(当前状态 ${observed})` : "") +
838
+ "。",
839
+ via: "agent prompt",
840
+ note: "不要再下发一次;用 herdr_read 确认它是否已开始工作。",
841
+ });
842
+ }
843
+ const semanticMessage = errorMessage(semantic.error);
844
+ // herdr 只接受 agent prompt 给「它自己命名并激活」的 agent;自报状态的 agent
845
+ // (dsh-tui)会回 agent_not_ready。文案匹配兜住不带结构化 code 的版本。
846
+ if (!/agent_not_ready|not an active named agent/i.test(semanticMessage)) {
847
+ return envelopeResult(failureEnvelope(semantic, { via: "agent prompt" }));
848
+ }
849
+ // pane run 不收裸 agent 名,而往猜出来的 pane 里键入比直接失败更糟。
850
+ const resolved = await attempt(signal, DEFAULT_LOCAL_TIMEOUT, (s) => resolveAgentPaneId(params.target, s));
851
+ if (resolved.ok !== true) {
852
+ return envelopeResult({
853
+ ok: false,
854
+ exitCode: 2,
855
+ output: resolved.timedOut ? semanticMessage : errorMessage(resolved.error),
856
+ note:
857
+ "agent prompt 不可用(对方是自报 agent),而 target 又不是完整 pane id," +
858
+ "无法确定该往哪个 pane 键入 —— 不猜。先用 herdr_overview 或 herdr_read 拿到完整 pane id 再发。",
859
+ });
860
+ }
861
+ const paneId = resolved.value;
862
+ const fallbackVia = `pane run ${paneId}(agent prompt 不可用:对方是自报 agent,非 herdr 命名 agent)`;
863
+ const typed = await textCall(["pane", "run", paneId, params.prompt], signal);
864
+ if (typed.ok !== true) return envelopeResult(failureEnvelope(typed, { via: fallbackVia }));
865
+ return envelopeResult({
866
+ ok: true,
867
+ exitCode: 0,
868
+ output: `Submitted prompt to pane ${paneId}`,
869
+ via: fallbackVia,
870
+ });
780
871
  },
781
872
  renderCall(args, theme, context) {
782
- return renderToolCall("herdr_pane", args, theme, context);
873
+ return renderVerbCall("herdr_prompt", args, theme, context);
783
874
  },
784
875
  renderResult(result, options, theme) {
785
- return renderToolResult(result, options, theme);
876
+ return renderVerbResult(result, options, theme);
786
877
  },
787
878
  });
788
879
 
789
880
  pi.registerTool({
790
- name: "herdr_agent",
791
- label: "Herdr Agent",
881
+ name: "herdr_run",
882
+ label: "Herdr Run",
883
+ outputSchema: envelopeOutputSchema,
792
884
  description:
793
- "Control a recognized coding agent occupying an existing Herdr pane. Starting requires an available interactive shell pane created through herdr_layout and never creates or changes layout. Agent targets are unique live names or the pane ID currently hosting the agent, never terminal IDs or bare kind labels. Use prompt, wait, read, and send_keys instead of raw pane input. Lifecycle states are working, blocked, done, idle, and unknown; prompt and wait default to the first settled idle, done, or blocked state. Read output is truncated to 2000 lines or 50KB.",
794
- promptSnippet: "Start, prompt, wait for, read, and interact with coding agents in Herdr",
885
+ "在指定 pane 里跑一条命令(裸终端,原子下发:文本 + Enter)。用来在别的 pane 起服务、跑脚本。成功 = 退出 0 且 stdout 为空,别期待回显信封;要确认结果用 herdr_read 或 herdr_wait。",
886
+ promptSnippet: "在另一个 pane 里原子下发一条命令",
795
887
  promptGuidelines: [
796
- "Use herdr_agent for recognized coding agents. Use herdr_layout to create an available shell pane first; herdr_agent start never creates or moves terminal layout.",
797
- "For normal helper work, use herdr_layout pane_split, then herdr_agent start, herdr_agent prompt with wait enabled, and herdr_agent read. Use herdr_pane only for ordinary processes or intentional raw terminal control.",
798
- "Treat herdr_agent idle and done as ready states, blocked as requiring inspection or input, and unknown as uncertain rather than completed. CLI reads do not mark done work as seen.",
799
- "When a prompt is rejected with agent_blocked, nothing was sent and retrying will fail the same way: inspect the blocking UI with get and read, and ask the operator before answering it.",
800
- "If herdr_agent read cannot recover a full alternate-screen response after increasing lines, ask the agent to write its complete response to a temporary Markdown file and return the path, then read that file directly.",
888
+ "herdr_run 只负责把命令送进 pane,不负责等它跑完;确认结果用 herdr_read 或 herdr_wait。",
889
+ "不要用 herdr_run 往自己所在的 pane 里下发命令:那会打断你正在进行的这一轮。",
890
+ "要驱动的是 coding agent 而不是普通 shell 时,用 herdr_prompt。",
801
891
  ],
802
892
  parameters: Type.Object({
803
- action: StringEnum(["list", "get", "start", "prompt", "wait", "read", "send_keys", "focus", "rename"] as const, {
804
- description: "Agent lifecycle action",
805
- }),
806
- target: Type.Optional(Type.String({ description: "Unique live agent name or pane ID currently hosting the agent" })),
807
- pane: Type.Optional(Type.String({ description: "Existing available shell pane ID for start" })),
808
- name: Type.Optional(
809
- Type.String({
810
- pattern: "^[a-z][a-z0-9_-]{0,31}$",
811
- description: "Unique agent name for start or replacement name for rename",
812
- }),
893
+ pane: Type.String({ description: "完整限定 pane id(w1B:pF)" }),
894
+ command: Type.String({ description: "要执行的命令" }),
895
+ }),
896
+ async execute(_toolCallId, params, signal) {
897
+ const self = process.env.HERDR_PANE_ID;
898
+ if (self !== undefined && params.pane === self) return envelopeResult(refusal(SELF_PANE_REFUSAL));
899
+ const result = await textCall(["pane", "run", params.pane, params.command], signal);
900
+ // 这里的成功是退出 0 且 stdout 为空;要求信封会把已经送达的命令报成失败。
901
+ if (result.ok !== true) return envelopeResult(failureEnvelope(result));
902
+ return envelopeResult({ ok: true, exitCode: 0, output: `Submitted command to pane ${params.pane}` });
903
+ },
904
+ renderCall(args, theme, context) {
905
+ return renderVerbCall("herdr_run", args, theme, context);
906
+ },
907
+ renderResult(result, options, theme) {
908
+ return renderVerbResult(result, options, theme);
909
+ },
910
+ });
911
+
912
+ pi.registerTool({
913
+ name: "herdr_create",
914
+ label: "Herdr Create",
915
+ outputSchema: envelopeOutputSchema,
916
+ description:
917
+ "开新位置:workspace / tab / 兄弟 pane。默认不动焦点(不加 --focus),避免把用户正在看的上下文切走。返回的新 pane id 才是后续操作要用的句柄。",
918
+ promptSnippet: "创建 workspace / tab / 兄弟 pane,并拿回新 pane id",
919
+ promptGuidelines: [
920
+ "开新位置用 herdr_create;返回的 pane id 是后续 herdr_run / herdr_prompt 的句柄,别自己拼。",
921
+ "默认不切焦点:用户正在看的上下文保持不变。确实要切才传 focus=true。",
922
+ "pane 是相对调用者自己的 pane 切分;要在别的 pane 里切,先切焦点或改用 workspace/tab。",
923
+ ],
924
+ parameters: Type.Object({
925
+ kind: CreateKindEnum,
926
+ workspace: Type.Optional(Type.String({ description: "tab 的目标 workspace(默认当前)。kind=workspace/pane 时忽略" })),
927
+ label: Type.Optional(Type.String({ description: "workspace/tab 的标签" })),
928
+ cwd: Type.Optional(Type.String({ description: "新位置的工作目录" })),
929
+ direction: Type.Optional(DirectionEnum),
930
+ focus: Type.Optional(Type.Boolean({ description: "是否把焦点切过去;默认 false" })),
931
+ }),
932
+ async execute(_toolCallId, params, signal) {
933
+ const focusFlag = params.focus === true ? "--focus" : "--no-focus";
934
+ const current = await attempt(signal, DEFAULT_LOCAL_TIMEOUT, (s) => getCurrentPane(s));
935
+ if (current.ok !== true) return envelopeResult(failureEnvelope(current));
936
+ const here = current.value;
937
+ const cwd = params.cwd || here.foreground_cwd || here.cwd || process.cwd();
938
+ if (params.kind === "workspace") {
939
+ const args = ["workspace", "create", "--cwd", cwd];
940
+ if (params.label !== undefined) args.push("--label", params.label);
941
+ args.push(focusFlag);
942
+ const created = await jsonCall<{
943
+ result: { workspace: WorkspaceInfo; tab: { tab_id: string }; root_pane: PaneInfo };
944
+ }>(args, signal);
945
+ if (created.ok !== true) return envelopeResult(failureEnvelope(created));
946
+ const { workspace, tab, root_pane: rootPane } = created.value.result;
947
+ return envelopeResult({
948
+ ok: true,
949
+ exitCode: 0,
950
+ output: `Created workspace ${workspace.workspace_id}, tab ${tab.tab_id}, root pane ${rootPane.pane_id}`,
951
+ });
952
+ }
953
+ if (params.kind === "tab") {
954
+ const args = ["tab", "create", "--workspace", params.workspace || here.workspace_id, "--cwd", cwd];
955
+ if (params.label !== undefined) args.push("--label", params.label);
956
+ args.push(focusFlag);
957
+ const created = await jsonCall<{ result: { tab: { tab_id: string }; root_pane: PaneInfo } }>(args, signal);
958
+ if (created.ok !== true) return envelopeResult(failureEnvelope(created));
959
+ const { tab, root_pane: rootPane } = created.value.result;
960
+ return envelopeResult({
961
+ ok: true,
962
+ exitCode: 0,
963
+ output: `Created tab ${tab.tab_id}, root pane ${rootPane.pane_id}`,
964
+ });
965
+ }
966
+ // 切分是相对调用者自己的 pane:--workspace 在这里没有意义。
967
+ const layout = await attempt(signal, DEFAULT_LOCAL_TIMEOUT, (s) => getPaneLayout(here.pane_id, s));
968
+ const direction =
969
+ params.direction || chooseSplitDirection(layout.ok === true ? layout.value : { panes: [] }, here.pane_id);
970
+ const args = ["pane", "split", here.pane_id, "--direction", direction, "--cwd", cwd, focusFlag];
971
+ const created = await jsonCall<{ result: { pane: PaneInfo } }>(args, signal);
972
+ if (created.ok !== true) return envelopeResult(failureEnvelope(created));
973
+ return envelopeResult({
974
+ ok: true,
975
+ exitCode: 0,
976
+ output: `Created pane ${created.value.result.pane.pane_id} by splitting ${here.pane_id} ${direction}`,
977
+ });
978
+ },
979
+ renderCall(args, theme, context) {
980
+ return renderVerbCall("herdr_create", args, theme, context);
981
+ },
982
+ renderResult(result, options, theme) {
983
+ return renderVerbResult(result, options, theme);
984
+ },
985
+ });
986
+
987
+ pi.registerTool({
988
+ name: "herdr_agent_start",
989
+ label: "Herdr Agent Start",
990
+ outputSchema: envelopeOutputSchema,
991
+ description:
992
+ "在一个已经停在交互提示符的 shell pane 里启动 coding agent。它不创建 pane、不移动布局 —— 要新位置先用 herdr_create。注意 kind 列表里没有 dsh-tui(dsh-tui 靠自报被识别,不由 agent start 起出来)。",
993
+ promptSnippet: "在空闲 shell pane 里启动一个 coding agent",
994
+ promptGuidelines: [
995
+ "先 herdr_create 拿到一个停在提示符的 shell pane,再 herdr_agent_start —— 它不会创建 pane,也不会移动布局。",
996
+ "name 必须是唯一的小写名字(^[a-z][a-z0-9_-]{0,31}$);后续 prompt / wait / read 都用这个名字或它的 pane id。",
997
+ "kind 只接受 herdr 认识的那几种;启动失败时先看 kind 是否在枚举里。",
998
+ ],
999
+ parameters: Type.Object({
1000
+ name: Type.String({ description: "给这个 agent 的唯一名字(^[a-z][a-z0-9_-]{0,31}$)" }),
1001
+ kind: AgentKindEnum,
1002
+ pane: Type.String({ description: "承载它的完整限定 pane id,必须是停在提示符的 shell pane" }),
1003
+ timeoutMs: Type.Optional(Type.Integer({ minimum: 1, description: "启动等待上限" })),
1004
+ agentArgs: Type.Optional(
1005
+ Type.Array(Type.String(), { description: "原样传给 agent 的额外参数(放在 -- 之后)。dsh manager 面没有这个参数,pi 侧保留" }),
813
1006
  ),
814
- kind: Type.Optional(AgentKindEnum),
815
- agentArgs: Type.Optional(Type.Array(Type.String(), { description: "Native agent arguments passed unchanged after -- for start" })),
816
- prompt: Type.Optional(Type.String({ description: "Prompt text submitted atomically with Enter" })),
817
- wait: Type.Optional(Type.Boolean({ description: "Wait for lifecycle settlement after prompt. Defaults to true." })),
818
- until: Type.Optional(Type.Array(StatusEnum, { description: "Accepted lifecycle states for prompt with wait or wait; defaults to idle, done, or blocked" })),
819
- timeout: Type.Optional(Type.Integer({ minimum: 1, description: "Timeout in milliseconds; omitted means indefinite" })),
820
- source: Type.Optional(ReadSourceEnum),
821
- lines: Type.Optional(Type.Integer({ minimum: 1, description: "Rendered terminal rows to read" })),
822
- format: Type.Optional(OutputFormatEnum),
823
- keys: Type.Optional(Type.Array(Type.String(), { description: "Logical UI keys such as esc, enter, up, or ctrl+c" })),
824
- clearName: Type.Optional(Type.Boolean({ description: "Clear the current agent name for rename" })),
825
1007
  }),
826
- async execute(_toolCallId, params, signal, onUpdate, _ctx) {
827
- switch (params.action) {
828
- case "list": {
829
- const response = await execHerdrJson<{ result: { agents: AgentInfo[] } }>(["agent", "list"], signal);
830
- const agents = response.result.agents || [];
831
- return {
832
- content: [{ type: "text", text: agents.length ? agents.map(summarizeAgent).join("\n") : "No agents." }],
833
- details: { action: "list", agents },
834
- };
835
- }
836
- case "get": {
837
- if (!params.target) throw new Error("'target' is required for get");
838
- const response = await execHerdrJson<{ result: { agent: AgentInfo } }>(["agent", "get", params.target], signal);
839
- return {
840
- content: [{ type: "text", text: summarizeAgent(response.result.agent) }],
841
- details: { action: "get", agent: response.result.agent },
842
- };
843
- }
844
- case "start": {
845
- if (!params.name) throw new Error("'name' is required for start");
846
- if (!params.kind) throw new Error("'kind' is required for start");
847
- if (!params.pane) throw new Error("'pane' is required for start");
848
- if (params.timeout != null && (params.timeout <= 3000 || params.timeout > 300000)) {
849
- throw new Error("start timeout must be greater than 3000ms and at most 300000ms");
850
- }
851
- const args = ["agent", "start", params.name, "--kind", params.kind, "--pane", params.pane];
852
- if (params.timeout != null) args.push("--timeout", String(params.timeout));
853
- if (params.agentArgs?.length) args.push("--", ...params.agentArgs);
854
- onUpdate?.({
855
- content: [{ type: "text", text: `Starting ${params.kind} as ${params.name} in ${params.pane}...` }],
856
- details: { action: "start", waiting: true },
857
- });
858
- const response = await execHerdrJson<{ result: { agent: AgentInfo } }>(args, signal);
859
- return {
860
- content: [{ type: "text", text: `Started ${summarizeAgent(response.result.agent)}` }],
861
- details: { action: "start", agent: response.result.agent },
862
- };
863
- }
864
- case "prompt": {
865
- if (!params.target) throw new Error("'target' is required for prompt");
866
- if (!params.prompt) throw new Error("'prompt' is required for prompt");
867
- const shouldWait = params.wait !== false;
868
- if (!shouldWait && params.until?.length) throw new Error("'until' requires wait for prompt");
869
- if (!shouldWait && params.timeout != null) throw new Error("'timeout' requires wait for prompt");
870
- const args = ["agent", "prompt", params.target, params.prompt];
871
- if (shouldWait) args.push("--wait");
872
- for (const status of params.until || []) args.push("--until", status);
873
- if (params.timeout != null) args.push("--timeout", String(params.timeout));
874
- if (shouldWait) {
875
- onUpdate?.({
876
- content: [{ type: "text", text: `Prompted ${params.target}; waiting for lifecycle settlement...` }],
877
- details: { action: "prompt", target: params.target, waiting: true },
878
- });
879
- }
880
- let response: { result: { agent: AgentInfo } } | null;
881
- let stalledState: string | undefined;
882
- try {
883
- response = await execHerdrJson<{ result: { agent: AgentInfo } }>(args, signal);
884
- } catch (error) {
885
- if (isPromptBlocked(error)) {
886
- const herdrMessage = error instanceof Error ? error.message : String(error);
887
- throw new Error(
888
- `${params.target} 处于 blocked(等待审批或回答),herdr 在发送任何输入前就拒绝了本次 prompt:${herdrMessage}\n` +
889
- "先用 herdr_agent get / read 看清阻塞的界面,向用户确认怎么答,再用 herdr_agent prompt 或 herdr_pane send_keys 回应;不要重复下发。",
890
- );
891
- }
892
- if (!isPromptStalled(error)) throw error;
893
- stalledState = /status is (\w+)/.exec(error instanceof Error ? error.message : "")?.[1];
894
- response = null;
895
- }
896
- if (response === null) {
897
- // herdr 在 submission accepted 后要求 5s 内观测到 working/blocked,观测不到就报
898
- // agent_prompt_stalled —— 这是启发式,prompt 已经下发,不能当成“下发失败”。
899
- return {
900
- content: [
901
- {
902
- type: "text",
903
- text:
904
- `Prompt 已下发到 ${params.target}(submission accepted),但 5s 内未观测到进入 working/blocked` +
905
- (stalledState ? `(当前状态 ${stalledState})` : "") +
906
- "。\n不要再下发一次;用 herdr_agent read 确认它是否已开始工作。",
907
- },
908
- ],
909
- details: {
910
- action: "prompt",
911
- target: params.target,
912
- submitted: true,
913
- stalled: true,
914
- observedState: stalledState,
915
- },
916
- };
917
- }
918
- return {
919
- content: [{ type: "text", text: `${shouldWait ? "Prompt settled" : "Prompt submitted"}: ${summarizeAgent(response.result.agent)}` }],
920
- details: { action: "prompt", agent: response.result.agent },
921
- };
922
- }
923
- case "wait": {
924
- if (!params.target) throw new Error("'target' is required for wait");
925
- const args = ["agent", "wait", params.target];
926
- for (const status of params.until || []) args.push("--until", status);
927
- if (params.timeout != null) args.push("--timeout", String(params.timeout));
928
- onUpdate?.({
929
- content: [{ type: "text", text: `Waiting for agent ${params.target}...` }],
930
- details: { action: "wait", target: params.target, waiting: true },
931
- });
932
- const response = await execHerdrJson<{ result: { agent: AgentInfo } }>(args, signal);
933
- return {
934
- content: [{ type: "text", text: `Agent settled: ${summarizeAgent(response.result.agent)}` }],
935
- details: { action: "wait", agent: response.result.agent },
936
- };
937
- }
938
- case "read": {
939
- if (!params.target) throw new Error("'target' is required for read");
940
- const args = ["agent", "read", params.target, "--source", params.source || "recent-unwrapped"];
941
- if (params.lines != null) args.push("--lines", String(params.lines));
942
- if (params.format) args.push("--format", params.format as OutputFormat);
943
- const output = await execHerdrText(args, signal);
944
- return {
945
- content: [{ type: "text", text: formatOutput(output) }],
946
- details: { action: "read", target: params.target, read: true, source: params.source || "recent-unwrapped" },
947
- };
948
- }
949
- case "send_keys": {
950
- if (!params.target) throw new Error("'target' is required for send_keys");
951
- if (!params.keys?.length) throw new Error("'keys' is required for send_keys");
952
- await execHerdrJson(["agent", "send-keys", params.target, ...params.keys], signal);
953
- return {
954
- content: [{ type: "text", text: `Sent ${params.keys.join(" ")} to ${params.target}` }],
955
- details: { action: "send_keys", target: params.target, keys: params.keys },
956
- };
957
- }
958
- case "focus": {
959
- if (!params.target) throw new Error("'target' is required for focus");
960
- const response = await execHerdrJson<{ result: { agent: AgentInfo } }>(["agent", "focus", params.target], signal);
961
- return {
962
- content: [{ type: "text", text: `Focused ${agentDisplayName(response.result.agent)}` }],
963
- details: { action: "focus", agent: response.result.agent },
964
- };
965
- }
966
- case "rename": {
967
- if (!params.target) throw new Error("'target' is required for rename");
968
- if (!params.clearName && !params.name) throw new Error("'name' or 'clearName' is required for rename");
969
- const args = ["agent", "rename", params.target];
970
- args.push(params.clearName ? "--clear" : params.name!);
971
- const response = await execHerdrJson<{ result: { agent: AgentInfo } }>(args, signal);
972
- return {
973
- content: [{ type: "text", text: params.clearName ? `Cleared agent name for ${params.target}` : `Renamed agent to ${params.name}` }],
974
- details: { action: "rename", agent: response.result.agent },
975
- };
976
- }
1008
+ async execute(_toolCallId, params, signal) {
1009
+ // herdr CLI 与 dsh 的 skill 脚本都在这一层校验;校验放在 body 里而不是 schema 的
1010
+ // pattern 上,让八个工具的失败都走同一套信封(同一约束只保留一处真值)。
1011
+ if (!AGENT_NAME_PATTERN.test(params.name ?? "")) {
1012
+ return envelopeResult(
1013
+ precondition("'name' must match ^[a-z][a-z0-9_-]{0,31}$(小写字母开头,可含数字/_/-,最长 32 字符)。"),
1014
+ );
1015
+ }
1016
+ if (params.timeoutMs != null && (params.timeoutMs <= START_TIMEOUT_MIN || params.timeoutMs > START_TIMEOUT_MAX)) {
1017
+ return envelopeResult(
1018
+ precondition(`start timeout must be > ${START_TIMEOUT_MIN}ms and <= ${START_TIMEOUT_MAX}ms(单位毫秒)。`),
1019
+ );
1020
+ }
1021
+ const args = ["agent", "start", params.name, "--kind", params.kind, "--pane", params.pane];
1022
+ if (params.timeoutMs != null) args.push("--timeout", String(params.timeoutMs));
1023
+ if (params.agentArgs?.length) args.push("--", ...params.agentArgs);
1024
+ const started = await jsonCall<{ result: { agent: AgentInfo } }>(args, signal);
1025
+ if (started.ok !== true) return envelopeResult(failureEnvelope(started));
1026
+ return envelopeResult({
1027
+ ok: true,
1028
+ exitCode: 0,
1029
+ output: `Started ${summarizeAgent(started.value.result.agent)}`,
1030
+ });
1031
+ },
1032
+ renderCall(args, theme, context) {
1033
+ return renderVerbCall("herdr_agent_start", args, theme, context);
1034
+ },
1035
+ renderResult(result, options, theme) {
1036
+ return renderVerbResult(result, options, theme);
1037
+ },
1038
+ });
1039
+
1040
+ pi.registerTool({
1041
+ name: "herdr_close",
1042
+ label: "Herdr Close",
1043
+ outputSchema: envelopeOutputSchema,
1044
+ description:
1045
+ "关 pane(会杀掉里面的进程)。默认 dryRun=true 只预览目标、不真关;确认目标无误后再显式传 dryRun=false。工具拒绝关闭自己所在的 pane,也绝不要关不是你创建的 pane。",
1046
+ promptSnippet: "预览或关闭一个 pane(默认只预览)",
1047
+ promptGuidelines: [
1048
+ "herdr_close 默认只预览:先看清要关的是哪个 pane,再显式传 dryRun=false 真关。",
1049
+ "关 pane 会杀掉里面的进程且不可撤销;不要关不是你创建的 pane,除非用户明确要求。",
1050
+ "工具永远拒绝关闭当前进程自己所在的 pane。",
1051
+ ],
1052
+ parameters: Type.Object({
1053
+ pane: Type.String({ description: "完整限定 pane id" }),
1054
+ dryRun: Type.Optional(Type.Boolean({ description: "默认 true:只预览。要真关必须显式传 false" })),
1055
+ }),
1056
+ async execute(_toolCallId, params, signal) {
1057
+ const current = await attempt(signal, DEFAULT_LOCAL_TIMEOUT, (s) => getCurrentPane(s));
1058
+ if (current.ok !== true) return envelopeResult(failureEnvelope(current));
1059
+ if (params.pane === current.value.pane_id) {
1060
+ return envelopeResult(
1061
+ precondition(
1062
+ "Refusing to close the pane pi is running in.\n 先跑 herdr_overview 确认自己的 pane;要关的是别的 pane。",
1063
+ ),
1064
+ );
1065
+ }
1066
+ // 关 pane 会杀掉进程且不可撤销:默认预览,让目标先落在屏幕上再动手。
1067
+ if (params.dryRun !== false) {
1068
+ const target = await attempt(signal, DEFAULT_LOCAL_TIMEOUT, (s) => getPane(params.pane, s));
1069
+ if (target.ok !== true) return envelopeResult(failureEnvelope(target));
1070
+ return envelopeResult({
1071
+ ok: true,
1072
+ exitCode: 0,
1073
+ output: `dry-run: would close ${summarizePane(target.value)}. 再调一次并传 dryRun=false 才会真的关。`,
1074
+ });
977
1075
  }
1076
+ const closed = await jsonCall(["pane", "close", params.pane], signal);
1077
+ if (closed.ok !== true) return envelopeResult(failureEnvelope(closed));
1078
+ return envelopeResult({ ok: true, exitCode: 0, output: `Closed pane ${params.pane}` });
978
1079
  },
979
1080
  renderCall(args, theme, context) {
980
- return renderToolCall("herdr_agent", args, theme, context);
1081
+ return renderVerbCall("herdr_close", args, theme, context);
981
1082
  },
982
1083
  renderResult(result, options, theme) {
983
- return renderToolResult(result, options, theme);
1084
+ return renderVerbResult(result, options, theme);
984
1085
  },
985
1086
  });
986
1087
  }