@muzi3/herdr 0.2.0 → 0.2.2

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.
@@ -13,6 +13,16 @@
13
13
  * 经 parseHerdrError 抛出,所以真实失败照旧报错,只有那个不成立的“stdout 必须非空”
14
14
  * 约束被去掉。`pane close` / `pane wait-output` / `agent send-keys` 确实返回 JSON
15
15
  * envelope,保持 execHerdrJson。回归测试见 tests/pane-agent-tools.test.ts。
16
+ *
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
+ * 命令产生的输出(回显在命令还没跑完时就已经存在)。
16
26
  */
17
27
 
18
28
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
@@ -91,6 +101,12 @@ interface HerdrJsonEnvelope {
91
101
  };
92
102
  }
93
103
 
104
+ interface WaitOutputResult {
105
+ pane_id: string;
106
+ matched_line: string;
107
+ read?: { text?: string };
108
+ }
109
+
94
110
  const StatusEnum = StringEnum(["idle", "working", "blocked", "done", "unknown"] as const, {
95
111
  description: "Agent lifecycle state",
96
112
  });
@@ -134,17 +150,58 @@ const AgentKindEnum = StringEnum(
134
150
  { description: "Supported coding agent kind and canonical executable" },
135
151
  );
136
152
 
137
- function parseHerdrError(output: string): string | null {
153
+ /**
154
+ * 字面量里带这些字符的 pattern(`a|b`、`log \$`)在字面量语义下永远不可能命中,
155
+ * 只会白等满 timeout,所以 wait_output 在未显式指定 regex 时按正则解释它们。
156
+ */
157
+ const REGEX_METACHARS = /[|()[\]{}^$*+?\\]/;
158
+
159
+ interface HerdrErrorDetail {
160
+ code?: string;
161
+ message: string;
162
+ }
163
+
164
+ function parseHerdrError(output: string): HerdrErrorDetail | null {
138
165
  const trimmed = output.trim();
139
166
  if (!trimmed) return null;
140
167
  try {
141
168
  const value = JSON.parse(trimmed) as HerdrJsonEnvelope;
142
- return value.error?.message || value.error?.code || trimmed;
169
+ const message = value.error?.message || value.error?.code;
170
+ if (message) return { code: value.error?.code, message };
171
+ return { message: trimmed };
143
172
  } catch {
144
- return trimmed;
173
+ return { message: trimmed };
145
174
  }
146
175
  }
147
176
 
177
+ /** 构造带 herdr code 的错误,让调用方能区分“检测未达成”和真正的失败。 */
178
+ function herdrError(message: string, code?: string): Error {
179
+ const error = new Error(message) as Error & { herdrCode?: string };
180
+ if (code) error.herdrCode = code;
181
+ return error;
182
+ }
183
+
184
+ function herdrErrorCode(error: unknown): string | undefined {
185
+ return error instanceof Error ? (error as Error & { herdrCode?: string }).herdrCode : undefined;
186
+ }
187
+
188
+ /**
189
+ * herdr 的 timeout / agent_prompt_stalled 表示“没观测到”,不是失败。
190
+ * code 是主要判据,文案匹配作为兜底(个别版本可能不带结构化 code)。
191
+ * stalled 的文案见过两种:旧版 `no observed state change ... state_change_seq remained N`,
192
+ * 0.9.0 `no observed working or blocked state within 5000 ms; current status is idle`。
193
+ */
194
+ function isHerdrTimeout(error: unknown): boolean {
195
+ return herdrErrorCode(error) === "timeout" || (error instanceof Error && /timed out waiting for/i.test(error.message));
196
+ }
197
+
198
+ function isPromptStalled(error: unknown): boolean {
199
+ return (
200
+ herdrErrorCode(error) === "agent_prompt_stalled" ||
201
+ (error instanceof Error && /no observed (?:state change|working or blocked state)/i.test(error.message))
202
+ );
203
+ }
204
+
148
205
  function isAbortError(error: unknown, signal?: AbortSignal): boolean {
149
206
  return signal?.aborted === true || (error instanceof Error && error.message === "Aborted");
150
207
  }
@@ -274,15 +331,23 @@ function renderToolResult(result: any, options: { expanded: boolean; isPartial:
274
331
  export default function (pi: ExtensionAPI) {
275
332
  if (process.env.HERDR_ENV !== "1" || !process.env.HERDR_PANE_ID) return;
276
333
 
334
+ /**
335
+ * 本进程最近一次下发到某个 pane 的文本(`run` 的命令、`send_text` 的字面量)。
336
+ * wait_output 用它识别“命中行其实就是刚敲下去的命令回显”,而不是命令产生的输出。
337
+ */
338
+ const lastSubmitted = new Map<string, string>();
339
+
277
340
  async function execHerdr(args: string[], signal?: AbortSignal) {
278
341
  const result = await pi.exec("herdr", args, { signal });
279
342
  if (signal?.aborted || result.killed) throw new Error("Aborted");
280
343
  if (result.code !== 0) {
281
- const message =
282
- parseHerdrError(result.stderr) ||
283
- parseHerdrError(result.stdout) ||
284
- `herdr ${args.join(" ")} failed with exit code ${result.code}`;
285
- throw new Error(message);
344
+ // herdr 把结构化错误写在 stderr(退出码 1)。带上 code,调用方才能区分
345
+ // timeout / agent_prompt_stalled 这类“没观测到”和真正的失败。
346
+ const detail = parseHerdrError(result.stderr) || parseHerdrError(result.stdout);
347
+ throw herdrError(
348
+ detail?.message || `herdr ${args.join(" ")} failed with exit code ${result.code}`,
349
+ detail?.code,
350
+ );
286
351
  }
287
352
  return result;
288
353
  }
@@ -297,7 +362,9 @@ export default function (pi: ExtensionAPI) {
297
362
  } catch {
298
363
  throw new Error(`Failed to parse JSON from herdr ${args.join(" ")}`);
299
364
  }
300
- if (value.error) throw new Error(value.error.message || value.error.code || `herdr ${args.join(" ")} failed`);
365
+ if (value.error) {
366
+ throw herdrError(value.error.message || value.error.code || `herdr ${args.join(" ")} failed`, value.error.code);
367
+ }
301
368
  return value as T;
302
369
  }
303
370
 
@@ -305,6 +372,25 @@ export default function (pi: ExtensionAPI) {
305
372
  return (await execHerdr(args, signal)).stdout;
306
373
  }
307
374
 
375
+ /**
376
+ * 读一次 pane 快照。失败时返回 null —— 读快照失败不能让 wait_output 本身失败。
377
+ */
378
+ async function readPaneSnapshot(
379
+ paneId: string,
380
+ source: WaitOutputSource,
381
+ lines: number | undefined,
382
+ signal?: AbortSignal,
383
+ ): Promise<string | null> {
384
+ const args = ["pane", "read", paneId, "--source", source];
385
+ if (lines != null) args.push("--lines", String(lines));
386
+ try {
387
+ return await execHerdrText(args, signal);
388
+ } catch (error) {
389
+ if (isAbortError(error, signal)) throw error;
390
+ return null;
391
+ }
392
+ }
393
+
308
394
  async function getCurrentPane(signal?: AbortSignal): Promise<PaneInfo> {
309
395
  const response = await execHerdrJson<{ result: { pane: PaneInfo } }>(["pane", "current", "--current"], signal);
310
396
  return response.result.pane;
@@ -490,6 +576,9 @@ export default function (pi: ExtensionAPI) {
490
576
  promptGuidelines: [
491
577
  "Use herdr_pane for ordinary commands and raw terminal control; use herdr_agent for coding-agent prompts, lifecycle waits, reads, and interactive keys.",
492
578
  "Use herdr_pane wait_output for tests, servers, builds, and watchers. It searches existing output immediately; use recent-unwrapped for logs and transcripts.",
579
+ "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.",
580
+ "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.",
581
+ "Never use a shell prompt (❯, $, \\$) as a wait_output pattern; it is always present and therefore meaningless.",
493
582
  "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.",
494
583
  ],
495
584
  parameters: Type.Object({
@@ -502,8 +591,15 @@ export default function (pi: ExtensionAPI) {
502
591
  keys: Type.Optional(
503
592
  Type.Array(Type.String(), { description: "Logical terminal keys for send_keys, such as esc, enter, up, or ctrl+c" }),
504
593
  ),
505
- match: Type.Optional(Type.String({ description: "Literal substring or Rust regular expression for wait_output" })),
506
- regex: Type.Optional(Type.Boolean({ description: "Treat match as a Rust regular expression" })),
594
+ match: Type.Optional(
595
+ Type.String({
596
+ description:
597
+ "Pattern for wait_output. Patterns containing regex metacharacters (| ( ) [ ] { } ^ $ * + ? \\) are treated as Rust regular expressions unless regex is explicitly false",
598
+ }),
599
+ ),
600
+ regex: Type.Optional(
601
+ Type.Boolean({ description: "Force match to be a literal string (false) or a Rust regular expression (true)" }),
602
+ ),
507
603
  source: Type.Optional(ReadSourceEnum),
508
604
  lines: Type.Optional(Type.Integer({ minimum: 1, description: "Rendered terminal rows to read or search" })),
509
605
  format: Type.Optional(OutputFormatEnum),
@@ -523,6 +619,7 @@ export default function (pi: ExtensionAPI) {
523
619
  if (!params.command) throw new Error("'command' is required for run");
524
620
  // pane run/send-text/send-keys report success with empty stdout, so only the exit code is meaningful.
525
621
  await execHerdr(["pane", "run", params.pane, params.command], signal);
622
+ lastSubmitted.set(params.pane, params.command);
526
623
  return {
527
624
  content: [{ type: "text", text: `Submitted command to pane ${params.pane}` }],
528
625
  details: { action: "run", pane: params.pane, command: params.command },
@@ -542,26 +639,92 @@ export default function (pi: ExtensionAPI) {
542
639
  if (!params.match) throw new Error("'match' is required for wait_output");
543
640
  if (params.source === "detection") throw new Error("wait_output does not support the detection source; use read");
544
641
  const startedAt = Date.now();
642
+ const source: WaitOutputSource = (params.source as WaitOutputSource) || "recent-unwrapped";
643
+ // 未显式指定 regex 时,含正则元字符的 pattern 按正则解释:字面量语义下
644
+ // `a|b` / `log \$` 永远不可能命中,只会白等满 timeout。
645
+ const matchMode: "literal" | "regex" =
646
+ params.regex === true
647
+ ? "regex"
648
+ : params.regex === false
649
+ ? "literal"
650
+ : REGEX_METACHARS.test(params.match)
651
+ ? "regex"
652
+ : "literal";
653
+ const notes: string[] = [];
654
+ if (matchMode === "literal" && REGEX_METACHARS.test(params.match)) {
655
+ notes.push(
656
+ `注意:${JSON.stringify(params.match)} 含正则元字符但按字面量匹配,很可能永远无法命中;需要正则请设 regex: true。`,
657
+ );
658
+ }
545
659
  onUpdate?.({
546
660
  content: [{ type: "text", text: `Waiting for output in ${params.pane}...` }],
547
661
  details: { action: "wait_output", pane: params.pane, waiting: true },
548
662
  });
549
- const args = ["pane", "wait-output", params.pane, params.regex ? "--regex" : "--match", params.match];
550
- if (params.source) args.push("--source", params.source as WaitOutputSource);
663
+ const args = ["pane", "wait-output", params.pane, matchMode === "regex" ? "--regex" : "--match", params.match];
664
+ args.push("--source", source);
551
665
  if (params.lines != null) args.push("--lines", String(params.lines));
552
666
  if (params.timeout != null) args.push("--timeout", String(params.timeout));
553
667
  if (params.raw) args.push("--raw");
554
- const response = await execHerdrJson<{
555
- result: { pane_id: string; matched_line: string; read?: { text?: string } };
556
- }>(args, signal);
668
+ let response: { result: WaitOutputResult } | null;
669
+ try {
670
+ response = await execHerdrJson<{ result: WaitOutputResult }>(args, signal);
671
+ } catch (error) {
672
+ if (!isHerdrTimeout(error)) throw error;
673
+ response = null;
674
+ }
675
+ if (response === null) {
676
+ // 超时是“没观测到”,不是失败:返回观测事实(含 pane 尾部输出)让调用方自己判断。
677
+ const tail = await readPaneSnapshot(params.pane, source, params.lines, signal);
678
+ const waitedMs = Date.now() - startedAt;
679
+ return {
680
+ content: [
681
+ {
682
+ type: "text",
683
+ text: [
684
+ `未匹配:${JSON.stringify(params.match)}(模式=${matchMode}, source=${source})在 ${waitedMs}ms 内未出现。`,
685
+ "这既不表示命令失败,也不表示命令完成,只表示这个 pattern 没有出现。",
686
+ ...notes,
687
+ tail ? `当前 pane 尾部输出:\n${formatOutput(tail)}` : "(未能读取 pane 尾部输出)",
688
+ ].join("\n"),
689
+ },
690
+ ],
691
+ details: {
692
+ action: "wait_output",
693
+ pane: params.pane,
694
+ matched: false,
695
+ waitedMs,
696
+ matchMode,
697
+ source,
698
+ tail,
699
+ },
700
+ };
701
+ }
557
702
  const matched = response.result;
558
703
  const output = matched.read?.text || matched.matched_line;
704
+ // 命中行可能来自刚下发的命令回显(`❯ <命令>`)而不是命令产生的输出,
705
+ // 回显在命令还没跑完时就已经存在。本进程自己记录的下发内容是最可靠的事实依据。
706
+ const submitted = lastSubmitted.get(params.pane);
707
+ const echoedInput = !!submitted && matched.matched_line.includes(submitted);
708
+ if (echoedInput) {
709
+ notes.push(
710
+ "注意:命中的是刚下发的命令回显行(❯ 后面那段),不是命令产生的输出;命令可能还没跑完。要等真正的输出,请改用只会出现在输出里的 pattern。",
711
+ );
712
+ }
559
713
  return {
560
- content: [{ type: "text", text: `Matched: ${matched.matched_line}\n\n${formatOutput(output)}` }],
714
+ content: [
715
+ {
716
+ type: "text",
717
+ text: [...notes, `Matched: ${matched.matched_line}`, "", formatOutput(output)].join("\n"),
718
+ },
719
+ ],
561
720
  details: {
562
721
  action: "wait_output",
563
722
  pane: params.pane,
723
+ matched: true,
564
724
  matchedLine: matched.matched_line,
725
+ matchMode,
726
+ source,
727
+ echoedInput,
565
728
  elapsedMs: Date.now() - startedAt,
566
729
  },
567
730
  };
@@ -569,6 +732,7 @@ export default function (pi: ExtensionAPI) {
569
732
  case "send_text": {
570
733
  if (!params.text) throw new Error("'text' is required for send_text");
571
734
  await execHerdr(["pane", "send-text", params.pane, params.text], signal);
735
+ lastSubmitted.set(params.pane, params.text);
572
736
  return {
573
737
  content: [{ type: "text", text: `Sent literal text to pane ${params.pane}` }],
574
738
  details: { action: "send_text", pane: params.pane },
@@ -691,7 +855,37 @@ export default function (pi: ExtensionAPI) {
691
855
  details: { action: "prompt", target: params.target, waiting: true },
692
856
  });
693
857
  }
694
- const response = await execHerdrJson<{ result: { agent: AgentInfo } }>(args, signal);
858
+ let response: { result: { agent: AgentInfo } } | null;
859
+ let stalledState: string | undefined;
860
+ try {
861
+ response = await execHerdrJson<{ result: { agent: AgentInfo } }>(args, signal);
862
+ } catch (error) {
863
+ if (!isPromptStalled(error)) throw error;
864
+ stalledState = /status is (\w+)/.exec(error instanceof Error ? error.message : "")?.[1];
865
+ response = null;
866
+ }
867
+ if (response === null) {
868
+ // herdr 在 submission accepted 后要求 5s 内观测到 working/blocked,观测不到就报
869
+ // agent_prompt_stalled —— 这是启发式,prompt 已经下发,不能当成“下发失败”。
870
+ return {
871
+ content: [
872
+ {
873
+ type: "text",
874
+ text:
875
+ `Prompt 已下发到 ${params.target}(submission accepted),但 5s 内未观测到进入 working/blocked` +
876
+ (stalledState ? `(当前状态 ${stalledState})` : "") +
877
+ "。\n不要再下发一次;用 herdr_agent read 确认它是否已开始工作。",
878
+ },
879
+ ],
880
+ details: {
881
+ action: "prompt",
882
+ target: params.target,
883
+ submitted: true,
884
+ stalled: true,
885
+ observedState: stalledState,
886
+ },
887
+ };
888
+ }
695
889
  return {
696
890
  content: [{ type: "text", text: `${shouldWait ? "Prompt settled" : "Prompt submitted"}: ${summarizeAgent(response.result.agent)}` }],
697
891
  details: { action: "prompt", agent: response.result.agent },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@muzi3/herdr",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
4
4
  "description": "Herdr for pi: three independent extensions (agent + blocked state reporting, /pi-hot-restart, and the herdr_layout / herdr_pane / herdr_agent pane control tools forked from @ogulcancelik/pi-herdr) plus the herdr-guide skill.",
5
5
  "keywords": [
6
6
  "pi-package",