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