@maxiaochao/pi-toolkit 0.9.6 → 0.10.0

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.
Files changed (53) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/README.md +1 -1
  3. package/package.json +5 -5
  4. package/packages/herdr-subagents/CHANGELOG.md +41 -0
  5. package/packages/herdr-subagents/README.md +155 -0
  6. package/packages/herdr-subagents/TESTING.md +24 -0
  7. package/packages/herdr-subagents/agents/explorer.md +8 -0
  8. package/packages/herdr-subagents/agents/reviewer.md +8 -0
  9. package/packages/{tiny-subagent → herdr-subagents}/agents/worker.md +1 -0
  10. package/packages/herdr-subagents/docs/demo.gif +0 -0
  11. package/packages/herdr-subagents/docs/screenshot.png +0 -0
  12. package/packages/herdr-subagents/extensions/herdr-subagents.ts +258 -0
  13. package/packages/herdr-subagents/extensions/subagent-report.ts +65 -0
  14. package/packages/{tiny-subagent → herdr-subagents}/package.json +7 -6
  15. package/packages/herdr-subagents/src/batch-runner.ts +219 -0
  16. package/packages/herdr-subagents/src/blocking.ts +175 -0
  17. package/packages/herdr-subagents/src/config.ts +125 -0
  18. package/packages/herdr-subagents/src/continue-run.ts +87 -0
  19. package/packages/herdr-subagents/src/dispatcher.ts +73 -0
  20. package/packages/herdr-subagents/src/errors.ts +3 -0
  21. package/packages/herdr-subagents/src/fleet-controller.ts +82 -0
  22. package/packages/herdr-subagents/src/fleet.ts +56 -0
  23. package/packages/herdr-subagents/src/herdr.ts +224 -0
  24. package/packages/herdr-subagents/src/history.ts +210 -0
  25. package/packages/herdr-subagents/src/live-agent.ts +87 -0
  26. package/packages/herdr-subagents/src/monitor.ts +18 -0
  27. package/packages/herdr-subagents/src/ownership.ts +17 -0
  28. package/packages/herdr-subagents/src/parameters.ts +21 -0
  29. package/packages/{tiny-subagent → herdr-subagents}/src/personas.ts +13 -2
  30. package/packages/herdr-subagents/src/protocol.ts +23 -0
  31. package/packages/herdr-subagents/src/queued-runs.ts +70 -0
  32. package/packages/herdr-subagents/src/reconcile.ts +167 -0
  33. package/packages/herdr-subagents/src/records.ts +213 -0
  34. package/packages/herdr-subagents/src/result-path.ts +7 -0
  35. package/packages/herdr-subagents/src/run-locator.ts +19 -0
  36. package/packages/herdr-subagents/src/run-pane-allocator.ts +78 -0
  37. package/packages/herdr-subagents/src/run-status.ts +23 -0
  38. package/packages/herdr-subagents/src/runner.ts +250 -0
  39. package/packages/herdr-subagents/src/state.ts +11 -0
  40. package/packages/herdr-subagents/src/tab-cleanup.ts +68 -0
  41. package/packages/herdr-subagents/src/tool.ts +217 -0
  42. package/packages/herdr-subagents/src/validation.ts +38 -0
  43. package/packages/herdr-subagents/tests/extension-contract.ts +225 -0
  44. package/packages/herdr-subagents/tests/run-tests.mjs +889 -0
  45. package/packages/todo/extensions/todo.ts +1 -1
  46. package/packages/tiny-subagent/CHANGELOG.md +0 -12
  47. package/packages/tiny-subagent/README.md +0 -128
  48. package/packages/tiny-subagent/TESTING.md +0 -20
  49. package/packages/tiny-subagent/docs/demo.gif +0 -0
  50. package/packages/tiny-subagent/docs/screenshot.png +0 -0
  51. package/packages/tiny-subagent/extensions/tiny-subagent.ts +0 -228
  52. package/packages/tiny-subagent/src/runner.ts +0 -319
  53. package/packages/tiny-subagent/tests/run-tests.mjs +0 -111
@@ -0,0 +1,225 @@
1
+ import { existsSync, mkdirSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
5
+ import { Container } from "@earendil-works/pi-tui";
6
+ import { registerHerdrSubagents } from "../extensions/herdr-subagents.ts";
7
+
8
+ function harness(execute: (...args: any[]) => Promise<any>) {
9
+ const handlers = new Map<string, (...args: any[]) => any>();
10
+ const commands: string[] = [];
11
+ let tool: Record<string, any> | undefined;
12
+ let terminalInput: ((data: string) => { consume?: boolean } | undefined) | undefined;
13
+ let widgetCalls = 0;
14
+ let renderedLines: string[] = [];
15
+ const editor = { kind: "editor" };
16
+ const focusedPanes: string[] = [];
17
+ const notifications: Array<{ message: string; level?: string }> = [];
18
+ const ui = {
19
+ setWidget(_id: string, widget: unknown) {
20
+ widgetCalls += 1;
21
+ if (typeof widget === "function") (widget as (tui: unknown) => unknown)({ focusedComponent: editor });
22
+ },
23
+ onTerminalInput(callback: typeof terminalInput) { terminalInput = callback; return () => { terminalInput = undefined; }; },
24
+ getEditorText() { return ""; },
25
+ notify(message: string, level?: string) { notifications.push({ message, level }); },
26
+ };
27
+ const pi = {
28
+ on(name: string, handler: (...args: any[]) => any) { handlers.set(name, handler); },
29
+ registerTool(definition: Record<string, any>) { tool = definition; },
30
+ registerCommand(name: string) { commands.push(name); },
31
+ sendMessage() {},
32
+ };
33
+ registerHerdrSubagents(pi as never, {
34
+ herdr: { async focusPane(paneId: string) { focusedPanes.push(paneId); } } as never,
35
+ execute: execute as never,
36
+ isEditor: (value) => value === editor,
37
+ createFleetWidget(lines) { renderedLines = lines; return new Container(); },
38
+ });
39
+ return {
40
+ handlers,
41
+ commands,
42
+ get tool() { return tool; },
43
+ get terminalInput() { return terminalInput; },
44
+ get widgetCalls() { return widgetCalls; },
45
+ get renderedLines() { return renderedLines; },
46
+ focusedPanes,
47
+ notifications,
48
+ ui,
49
+ };
50
+ }
51
+
52
+ async function startHarness(instance: ReturnType<typeof harness>, cwd: string) {
53
+ await instance.handlers.get("session_start")?.({}, {
54
+ cwd,
55
+ mode: "tui",
56
+ ui: instance.ui,
57
+ sessionManager: { getSessionId: () => "contract-session" },
58
+ });
59
+ }
60
+
61
+ async function verifyPublicUiWiring() {
62
+ const cwd = mkdtempSync(join(tmpdir(), "herdr-extension-contract-"));
63
+ try {
64
+ const blockedDetails = {
65
+ runId: "blocked-run", label: "reviews", status: "blocked", summary: "second needs input", documents: [],
66
+ activity: [
67
+ { index: 0, name: "first", status: "completed", paneId: "w1:p1" },
68
+ { index: 1, name: "second", status: "blocked", paneId: "w1:p2" },
69
+ ],
70
+ };
71
+ const responding = harness(async () => ({ content: [{ type: "text", text: "needs input" }], details: blockedDetails }));
72
+ await startHarness(responding, cwd);
73
+ if (!responding.tool) throw new Error("herdr_subagents was not registered for UI wiring test");
74
+ const renderedBlocked = responding.tool.renderResult({ content: [{ type: "text", text: "needs input" }], details: { agent: "explorer", status: "blocked", ok: false, summary: "Need input.", documents: [] } }, { isPartial: false }, { fg: (_color: string, text: string) => text, bold: (text: string) => text }).render(100).join("\n");
75
+ if (!renderedBlocked.includes("blocked") || renderedBlocked.includes("failed")) throw new Error("single-task blocked result did not render its authoritative status");
76
+ await responding.tool.execute("call", { action: "respond", runId: "blocked-run", answer: "main" }, undefined, undefined, { cwd, ui: responding.ui });
77
+ if (!responding.renderedLines.some((line) => line.includes("second") && line.includes("blocked"))) throw new Error("blocked response did not refresh Fleet rows");
78
+ if (!responding.terminalInput?.("\u001b[B")?.consume || !responding.terminalInput?.("\u001b[B")?.consume || !responding.terminalInput?.("\r")?.consume) throw new Error("Fleet terminal input was not consumed while selecting");
79
+ await new Promise((resolve) => setTimeout(resolve, 0));
80
+ if (responding.focusedPanes.join(",") !== "w1:p2") throw new Error("Enter did not focus the exact refreshed blocked pane");
81
+ if (responding.terminalInput?.("x")?.consume) throw new Error("unrelated terminal input must pass through");
82
+
83
+ let releaseFirst: ((value: unknown) => void) | undefined;
84
+ let backgroundCalls = 0;
85
+ const runningDetails = { runId: "active-run", label: "active", status: "running", summary: "working", documents: [], tasks: [], activity: [{ index: 0, name: "active task", status: "running", paneId: "w1:p3" }] };
86
+ const background = harness(async (_params, _signal, onUpdate) => {
87
+ backgroundCalls += 1;
88
+ onUpdate?.({ content: [{ type: "text", text: "working" }], details: runningDetails });
89
+ if (backgroundCalls > 1) return { content: [{ type: "text", text: "done" }], details: { ...runningDetails, status: "completed" } };
90
+ return await new Promise((resolve) => { releaseFirst = resolve; });
91
+ });
92
+ await startHarness(background, cwd);
93
+ if (!background.tool) throw new Error("herdr_subagents was not registered for queue wiring test");
94
+ await background.tool.execute("first", { background: true, tasks: [{ name: "active task", prompt: "one" }] }, undefined, undefined, { cwd, ui: background.ui });
95
+ const activeWidgetCalls = background.widgetCalls;
96
+ await background.tool.execute("second", { background: true, tasks: [{ name: "queued task", prompt: "two" }] }, undefined, undefined, { cwd, ui: background.ui });
97
+ if (background.widgetCalls !== activeWidgetCalls || !background.renderedLines.some((line) => line.includes("active task"))) throw new Error("queued background run replaced the active Fleet widget");
98
+ releaseFirst?.({ content: [{ type: "text", text: "done" }], details: { ...runningDetails, status: "completed" } });
99
+ await new Promise((resolve) => setTimeout(resolve, 10));
100
+
101
+ const rejectedCwd = join(cwd, "rejected-background");
102
+ mkdirSync(rejectedCwd, { recursive: true });
103
+ const rejectedBackground = harness(async () => ({ content: [{ type: "text", text: "Unknown persona" }], details: { errorMessage: "Unknown persona" }, isError: true }));
104
+ await startHarness(rejectedBackground, rejectedCwd);
105
+ await rejectedBackground.tool?.execute("rejected", { background: true, tasks: [{ name: "invalid", prompt: "reject" }] }, undefined, undefined, { cwd: rejectedCwd, ui: rejectedBackground.ui });
106
+ await new Promise((resolve) => setTimeout(resolve, 10));
107
+ const queueDirectory = join(rejectedCwd, ".pi", "herdr-subagents", "queue");
108
+ if (existsSync(queueDirectory) && readdirSync(queueDirectory).some((name) => name.endsWith(".json"))) throw new Error("rejected background batch left a durable queue entry");
109
+
110
+ let lateUpdate: ((response: any) => void) | undefined;
111
+ const cleanupNotice = harness(async (_params, _signal, onUpdate) => {
112
+ lateUpdate = onUpdate;
113
+ return { content: [{ type: "text", text: "done" }], details: { status: "completed" } };
114
+ });
115
+ await startHarness(cleanupNotice, cwd);
116
+ await cleanupNotice.tool?.execute("cleanup", { prompt: "inspect" }, undefined, undefined, { cwd, ui: cleanupNotice.ui });
117
+ lateUpdate?.({ content: [{ type: "text", text: "Task results settled, but Herdr tab cleanup failed: busy" }], details: { status: "failed" } });
118
+ if (!cleanupNotice.notifications.some(({ message, level }) => message.includes("cleanup failed") && level === "warning")) throw new Error("deferred foreground cleanup failure was not surfaced as a notification");
119
+
120
+ const brokenRoot = join(cwd, "broken-recovery");
121
+ mkdirSync(join(brokenRoot, ".pi", "herdr-subagents"), { recursive: true });
122
+ writeFileSync(join(brokenRoot, ".pi", "herdr-subagents", "runs"), "not a directory");
123
+ let resumedExecutions = 0;
124
+ const recoveryFailure = harness(async () => {
125
+ resumedExecutions += 1;
126
+ return { content: [{ type: "text", text: "done" }], details: { runId: "after-recovery", label: "after recovery", status: "completed", summary: "done", documents: [], tasks: [], activity: [] } };
127
+ });
128
+ await startHarness(recoveryFailure, brokenRoot);
129
+ await Promise.race([
130
+ recoveryFailure.tool?.execute("after-recovery", { tasks: [{ name: "task", prompt: "continue" }] }, undefined, undefined, { cwd: brokenRoot, ui: recoveryFailure.ui }),
131
+ new Promise((_, reject) => setTimeout(() => reject(new Error("dispatcher stayed paused after recovery failure")), 100)),
132
+ ]);
133
+ if (resumedExecutions !== 1 || !recoveryFailure.notifications.some(({ message }) => message.includes("recovery could not inspect"))) throw new Error("recovery failure did not resume dispatch and notify the user");
134
+
135
+ const sessionAbortRoot = join(cwd, "session-abort");
136
+ mkdirSync(sessionAbortRoot, { recursive: true });
137
+ let retainedSignal: AbortSignal | undefined;
138
+ const sessionAbort = harness(async (_params, signal) => {
139
+ retainedSignal = signal;
140
+ return { content: [{ type: "text", text: "done" }], details: { status: "completed" } };
141
+ });
142
+ await startHarness(sessionAbort, sessionAbortRoot);
143
+ await sessionAbort.tool?.execute("session-abort", { prompt: "inspect" }, undefined, undefined, { cwd: sessionAbortRoot, ui: sessionAbort.ui });
144
+ if (!retainedSignal || retainedSignal.aborted) throw new Error("execution did not retain a live parent-session signal");
145
+ await sessionAbort.handlers.get("session_shutdown")?.({}, { cwd: sessionAbortRoot, ui: sessionAbort.ui });
146
+ if (!retainedSignal.aborted) throw new Error("parent shutdown did not abort deferred cleanup signal");
147
+ } finally {
148
+ rmSync(cwd, { recursive: true, force: true });
149
+ }
150
+ }
151
+
152
+ const originalHerdrEnv = process.env.HERDR_ENV;
153
+ const originalWorkspaceId = process.env.HERDR_WORKSPACE_ID;
154
+ const originalChild = process.env.PI_HERDR_SUBAGENTS_CHILD;
155
+ try {
156
+ delete process.env.HERDR_ENV;
157
+ delete process.env.HERDR_WORKSPACE_ID;
158
+ delete process.env.PI_HERDR_SUBAGENTS_CHILD;
159
+ const outside = harness(async () => { throw new Error("unreachable"); });
160
+ if (outside.tool || outside.commands.length || outside.handlers.size) throw new Error("Subagents registered outside Herdr");
161
+ process.env.HERDR_ENV = "1";
162
+ const missingWorkspace = harness(async () => { throw new Error("unreachable"); });
163
+ if (missingWorkspace.tool || missingWorkspace.commands.length || missingWorkspace.handlers.size) throw new Error("Subagents registered without a Herdr workspace");
164
+ process.env.HERDR_ENV = "0";
165
+ process.env.HERDR_WORKSPACE_ID = "w1";
166
+ const inactiveHerdr = harness(async () => { throw new Error("unreachable"); });
167
+ if (inactiveHerdr.tool || inactiveHerdr.commands.length || inactiveHerdr.handlers.size) throw new Error("Subagents registered with HERDR_ENV=0");
168
+ process.env.HERDR_ENV = "1";
169
+ const inside = harness(async () => { throw new Error("unreachable"); });
170
+ if (!inside.tool || !inside.commands.includes("herdr-subagents") || !inside.handlers.has("session_start")) throw new Error("Subagents did not register inside Herdr");
171
+ process.env.PI_HERDR_SUBAGENTS_CHILD = "1";
172
+ const child = harness(async () => { throw new Error("unreachable"); });
173
+ if (child.tool || child.commands.length || child.handlers.size) throw new Error("Subagents registered inside a child Agent");
174
+ delete process.env.PI_HERDR_SUBAGENTS_CHILD;
175
+ await verifyPublicUiWiring();
176
+ } finally {
177
+ if (originalHerdrEnv === undefined) delete process.env.HERDR_ENV;
178
+ else process.env.HERDR_ENV = originalHerdrEnv;
179
+ if (originalWorkspaceId === undefined) delete process.env.HERDR_WORKSPACE_ID;
180
+ else process.env.HERDR_WORKSPACE_ID = originalWorkspaceId;
181
+ if (originalChild === undefined) delete process.env.PI_HERDR_SUBAGENTS_CHILD;
182
+ else process.env.PI_HERDR_SUBAGENTS_CHILD = originalChild;
183
+ }
184
+
185
+ export default function (_pi: ExtensionAPI) {
186
+ let tool: Record<string, any> | undefined;
187
+ const pi = {
188
+ on() {},
189
+ registerTool(definition: Record<string, any>) { tool = definition; },
190
+ registerCommand() {},
191
+ sendMessage() {},
192
+ };
193
+
194
+ const previousHerdrEnv = process.env.HERDR_ENV;
195
+ const previousWorkspaceId = process.env.HERDR_WORKSPACE_ID;
196
+ const previousChild = process.env.PI_HERDR_SUBAGENTS_CHILD;
197
+ try {
198
+ process.env.HERDR_ENV = "1";
199
+ process.env.HERDR_WORKSPACE_ID = "w1";
200
+ delete process.env.PI_HERDR_SUBAGENTS_CHILD;
201
+ registerHerdrSubagents(pi as never, { herdr: {} as never });
202
+ } finally {
203
+ if (previousHerdrEnv === undefined) delete process.env.HERDR_ENV;
204
+ else process.env.HERDR_ENV = previousHerdrEnv;
205
+ if (previousWorkspaceId === undefined) delete process.env.HERDR_WORKSPACE_ID;
206
+ else process.env.HERDR_WORKSPACE_ID = previousWorkspaceId;
207
+ if (previousChild === undefined) delete process.env.PI_HERDR_SUBAGENTS_CHILD;
208
+ else process.env.PI_HERDR_SUBAGENTS_CHILD = previousChild;
209
+ }
210
+ if (!tool) throw new Error("herdr_subagents was not registered");
211
+ const schema = tool.parameters as Record<string, any>;
212
+ if (schema.type !== "object" || schema.anyOf || schema.oneOf) throw new Error("herdr_subagents parameters must be one flat object schema");
213
+ const required = ["action", "runId", "answer", "task", "prompt", "agent", "label", "model", "tasks", "concurrency", "background"];
214
+ for (const name of required) {
215
+ if (!schema.properties?.[name]) throw new Error(`schema is missing property '${name}'`);
216
+ if (!schema.properties[name].description) throw new Error(`schema property '${name}' has no description`);
217
+ }
218
+ for (const name of ["name", "prompt", "agent", "model"]) {
219
+ const property = schema.properties.tasks.items.properties[name];
220
+ if (!property?.description) throw new Error(`nested task property '${name}' has no description`);
221
+ }
222
+ if (!/Foreground calls block/.test(tool.description) || !/background batches return a run ID immediately/.test(tool.description)) {
223
+ throw new Error("tool description does not distinguish foreground and background execution");
224
+ }
225
+ }