@maxiaochao/pi-toolkit 0.9.6 → 0.10.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.
Files changed (56) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/README.md +1 -2
  3. package/package.json +5 -5
  4. package/packages/herdr-subagents/CHANGELOG.md +50 -0
  5. package/packages/herdr-subagents/README.md +283 -0
  6. package/packages/herdr-subagents/TESTING.md +25 -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.webm +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 +129 -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 +895 -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
  54. package/skills/calldiff/LICENSE +0 -21
  55. package/skills/calldiff/NOTICE +0 -19
  56. package/skills/calldiff/SKILL.md +0 -121
@@ -0,0 +1,258 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { fileURLToPath } from "node:url";
3
+ import type { AgentToolResult, ExtensionAPI, ExtensionCommandContext, Theme, ToolRenderResultOptions } from "@earendil-works/pi-coding-agent";
4
+ import { Container, Editor, Spacer, Text } from "@earendil-works/pi-tui";
5
+ import { loadSubagentConfiguration } from "../src/config.ts";
6
+ import { CliHerdrAutomation, type HerdrAutomation } from "../src/herdr.ts";
7
+ import { activeRunText } from "../src/monitor.ts";
8
+ import { RunDispatcher } from "../src/dispatcher.ts";
9
+ import { historyText, listSubagentHistory } from "../src/history.ts";
10
+ import { cancelQueuedRuns, loadQueuedRuns, persistQueuedRun, removeQueuedRun } from "../src/queued-runs.ts";
11
+ import { cancelActiveSubagentRuns, reconcileSubagentRuns } from "../src/reconcile.ts";
12
+ import { executeHerdrSubagents, type HerdrSubagentsBatchDetails, type HerdrSubagentsDetails, type HerdrSubagentsToolParams } from "../src/tool.ts";
13
+ import { HerdrSubagentsParams } from "../src/parameters.ts";
14
+ import { HERDR_ACTIONS } from "../src/validation.ts";
15
+ import { FleetController } from "../src/fleet-controller.ts";
16
+ import { isSettledRunStatus } from "../src/records.ts";
17
+
18
+ const packageAgentsDir = fileURLToPath(new URL("../agents/", import.meta.url));
19
+
20
+ function statusVisual(status: string): { color: "success" | "warning" | "error" | "accent"; glyph: string } {
21
+ if (status === "completed") return { color: "success", glyph: "✓" };
22
+ if (status === "blocked") return { color: "warning", glyph: "?" };
23
+ if (status === "cancelled" || status === "partial") return { color: "warning", glyph: "−" };
24
+ if (status === "starting" || status === "queued" || status === "running") return { color: "accent", glyph: "·" };
25
+ return { color: "error", glyph: "✗" };
26
+ }
27
+
28
+ function renderToolCall(args: HerdrSubagentsToolParams, theme: Theme): Text {
29
+ if (args.action) {
30
+ const action = HERDR_ACTIONS[args.action];
31
+ const runId = action.includeRunId ? ` ${theme.fg("muted", String(args.runId ?? ""))}` : "";
32
+ return new Text(`${theme.fg("toolTitle", theme.bold(`herdr_subagents ${args.action}`))}${runId}`, 0, 0);
33
+ }
34
+ if (args.tasks) return new Text(`${theme.fg("toolTitle", theme.bold("herdr_subagents"))} ${theme.fg("accent", `${args.tasks.length} tasks`)}${theme.fg("muted", ` · concurrency ${args.concurrency ?? "default"}`)}`, 0, 0);
35
+ const agent = args.agent || "worker";
36
+ const label = args.label ? ` · ${String(args.label)}` : "";
37
+ const prompt = args.prompt ? String(args.prompt).replace(/\s+/g, " ") : "...";
38
+ const preview = prompt.length > 80 ? `${prompt.slice(0, 80)}...` : prompt;
39
+ return new Text(`${theme.fg("toolTitle", theme.bold("herdr_subagents"))} ${theme.fg("accent", agent)}${theme.fg("muted", label)}\n ${theme.fg("dim", preview)}`, 0, 0);
40
+ }
41
+
42
+ function renderToolResult(result: AgentToolResult<unknown>, _options: ToolRenderResultOptions, theme: Theme): Container | Text {
43
+ const batch = result.details as HerdrSubagentsBatchDetails | undefined;
44
+ if (batch?.tasks && "effectiveConcurrency" in batch) {
45
+ const container = new Container();
46
+ const visual = statusVisual(batch.status);
47
+ container.addChild(new Text(`${theme.fg("toolTitle", theme.bold(batch.label))} ${theme.fg(visual.color, batch.status)} · ${batch.tasks.length} settled · concurrency ${batch.effectiveConcurrency}`, 0, 0));
48
+ for (const task of batch.tasks) container.addChild(new Text(`${statusVisual(task.status).glyph} ${task.name}: ${task.summary}`, 0, 0));
49
+ return container;
50
+ }
51
+ const details = result.details as HerdrSubagentsDetails | undefined;
52
+ if (!details?.agent) {
53
+ const content = result.content[0];
54
+ return new Text(content?.type === "text" ? content.text : "(no output)", 0, 0);
55
+ }
56
+ const container = new Container();
57
+ container.addChild(new Text(`${theme.fg("toolTitle", theme.bold(details.agent))} ${theme.fg(statusVisual(details.status).color, details.status)}`, 0, 0));
58
+ container.addChild(new Text(details.summary, 0, 0));
59
+ if (details.documents.length > 0) {
60
+ container.addChild(new Spacer(1));
61
+ for (const document of details.documents) container.addChild(new Text(theme.fg("muted", `${document.description}: ${document.path}`), 0, 0));
62
+ }
63
+ if (details.recordDirectory) container.addChild(new Text(theme.fg("dim", `Record: ${details.recordDirectory}`), 0, 0));
64
+ if (details.errorMessage) container.addChild(new Text(theme.fg("error", `Error: ${details.errorMessage}`), 0, 0));
65
+ return container;
66
+ }
67
+
68
+ async function handleHerdrCommand(args: string, ctx: ExtensionCommandContext, fleet: FleetController): Promise<void> {
69
+ const section = args.trim() || "active";
70
+ if (section === "active") {
71
+ ctx.ui.notify(fleet.active ? activeRunText(fleet.active) : "No active Subagent run.", "info");
72
+ return;
73
+ }
74
+ if (section === "history") {
75
+ ctx.ui.notify(historyText(await listSubagentHistory(ctx.cwd)), "info");
76
+ return;
77
+ }
78
+ if (section.startsWith("focus ")) {
79
+ if (!await fleet.focus(Number(section.slice(6).trim()))) ctx.ui.notify("That task is not active in a Herdr pane.", "warning");
80
+ return;
81
+ }
82
+ if (section === "models") {
83
+ await ctx.modelRegistry.refresh();
84
+ const lines = ctx.modelRegistry.getAvailable().map((model) => `${model.provider}/${model.id}`).sort();
85
+ ctx.ui.notify(lines.length ? `Available Pi models:\n${lines.join("\n")}` : "No authenticated Pi models are available.", "info");
86
+ return;
87
+ }
88
+ const configuration = loadSubagentConfiguration(ctx.cwd, packageAgentsDir, ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : undefined);
89
+ if (section === "agents") {
90
+ ctx.ui.notify(configuration.personas.map((persona) => `${persona.name}: ${persona.source}, ${persona.access}, model=${persona.model ?? "unresolved"} (${persona.modelSource}), thinking=${persona.thinking ?? "default"}, tools=${persona.tools?.join(",") ?? "default"}, skills=${persona.skills.join(",") || "none"}`).join("\n"), "info");
91
+ return;
92
+ }
93
+ if (section === "settings") {
94
+ const settings = configuration.settings;
95
+ const values: Record<string, string | number | undefined> = { defaultConcurrency: settings.defaultConcurrency, maxConcurrency: settings.maxConcurrency, stalledWarningSeconds: settings.stalledWarningSeconds, defaultModel: settings.defaultModel, defaultThinking: settings.defaultThinking };
96
+ const lines = Object.entries(values).map(([key, value]) => `${key}=${String(value ?? "unset")} (${configuration.settingSources[key] ?? "built-in"})`);
97
+ if (configuration.diagnostics.length) lines.push("Diagnostics:", ...configuration.diagnostics);
98
+ ctx.ui.notify(lines.join("\n"), "info");
99
+ return;
100
+ }
101
+ ctx.ui.notify("Usage: /herdr-subagents active|history|focus <task-number>|agents|models|settings", "warning");
102
+ }
103
+
104
+ export interface HerdrSubagentsExtensionDependencies {
105
+ herdr?: HerdrAutomation;
106
+ execute?: typeof executeHerdrSubagents;
107
+ isEditor?: (value: unknown) => boolean;
108
+ createFleetWidget?: (lines: string[]) => Container;
109
+ }
110
+
111
+ async function recoverParentSession(cwd: string, herdr: HerdrAutomation, owner: { sessionId?: string; processId: number }, notify: (message: string) => void): Promise<void> {
112
+ try {
113
+ const queued = await loadQueuedRuns(cwd, notify);
114
+ await cancelQueuedRuns(cwd, queued.map((run) => run.runId), { reason: "Owning parent Pi session is no longer active.", owner, onlyOrphaned: true, onError: notify });
115
+ }
116
+ catch (error) { notify(`Subagent queue recovery failed: ${error instanceof Error ? error.message : String(error)}`); }
117
+ try {
118
+ const reconciliation = await reconcileSubagentRuns(cwd, herdr, owner);
119
+ for (const message of reconciliation.cleanupErrors) notify(message);
120
+ }
121
+ catch (error) { notify(`Subagent recovery could not inspect active runs: ${error instanceof Error ? error.message : String(error)}`); }
122
+ }
123
+
124
+ async function shutdownParentSession(options: { cwd: string; herdr: HerdrAutomation; owner: { sessionId?: string; processId: number }; dispatcher: RunDispatcher; fleet: FleetController; sessionController: AbortController; runControllers: Map<string, AbortController>; inFlight: Set<Promise<unknown>>; notify: (message: string) => void }): Promise<void> {
125
+ options.dispatcher.pause();
126
+ options.sessionController.abort(new Error("Parent Pi session closed."));
127
+ const queuedRunIds = options.dispatcher.cancelQueued(new Error("Parent Pi session closed."));
128
+ await cancelQueuedRuns(options.cwd, queuedRunIds, { reason: "Parent Pi session closed.", owner: options.owner });
129
+ for (const controller of options.runControllers.values()) controller.abort(new Error("Parent Pi session closed."));
130
+ options.fleet.shutdown();
131
+ await Promise.allSettled([...options.inFlight]);
132
+ const cancellation = await cancelActiveSubagentRuns(options.cwd, options.herdr, { reason: "Parent Pi session closed.", owner: options.owner });
133
+ const retainedRunId = options.dispatcher.snapshot().activeRunId;
134
+ if (retainedRunId) options.dispatcher.release(retainedRunId);
135
+ for (const message of cancellation.cleanupErrors) options.notify(message);
136
+ }
137
+
138
+ export function registerHerdrSubagents(pi: ExtensionAPI, dependencies: HerdrSubagentsExtensionDependencies = {}) {
139
+ if (process.env.PI_HERDR_SUBAGENTS_CHILD === "1" || process.env.HERDR_ENV !== "1" || !process.env.HERDR_WORKSPACE_ID) return;
140
+ const herdr = dependencies.herdr ?? new CliHerdrAutomation();
141
+ const execute = dependencies.execute ?? executeHerdrSubagents;
142
+ const isEditorComponent = dependencies.isEditor ?? ((value: unknown) => value instanceof Editor);
143
+ const dispatcher = new RunDispatcher();
144
+ const fleet = new FleetController(herdr, isEditorComponent, dependencies.createFleetWidget);
145
+ const runControllers = new Map<string, AbortController>();
146
+ const inFlight = new Set<Promise<unknown>>();
147
+ let sessionController = new AbortController();
148
+ let ownerSessionId: string | undefined;
149
+ let sessionGeneration = 0;
150
+ const notifyRun = (runId: string, result: { details: unknown; content?: Array<{ type: string; text?: string }> }) => {
151
+ const details = result.details as Partial<HerdrSubagentsBatchDetails> & { errorMessage?: string };
152
+ if (!details.tasks || !details.documents || !details.status) {
153
+ const error = details.errorMessage ?? result.content?.find((item) => item.type === "text")?.text ?? "Unknown Subagent failure.";
154
+ pi.sendMessage({ customType: "herdr-subagents-run", content: `Subagent run ${runId} failed: ${error}`, display: true, details: { runId, status: "failed" } }, { triggerTurn: true, deliverAs: "followUp" });
155
+ return;
156
+ }
157
+ const lines = [`Subagent run ${runId} ${details.status}: ${details.summary}`, ...details.tasks.map((task) => `- ${task.name}: ${task.summary}`)];
158
+ if (details.documents.length) lines.push("Documents:", ...details.documents.map((document) => `- ${document.description}: ${document.path}`));
159
+ pi.sendMessage({ customType: "herdr-subagents-run", content: lines.join("\n"), display: true, details: { runId, status: details.status } }, { triggerTurn: true, deliverAs: "followUp" });
160
+ };
161
+
162
+ pi.on("session_shutdown", async (_event, ctx) => {
163
+ sessionGeneration += 1;
164
+ await shutdownParentSession({ cwd: ctx.cwd, herdr, owner: { sessionId: ownerSessionId, processId: process.pid }, dispatcher, fleet, sessionController, runControllers, inFlight, notify: (message) => ctx.ui.notify(message, "error") });
165
+ });
166
+ pi.on("session_start", async (_event, ctx) => {
167
+ sessionGeneration += 1;
168
+ sessionController.abort(new Error("Parent Pi session changed."));
169
+ sessionController = new AbortController();
170
+ ownerSessionId = `${ctx.sessionManager.getSessionId()}:${randomUUID()}`;
171
+ fleet.bind(ctx.ui, ctx.mode === "tui");
172
+ dispatcher.pause();
173
+ try { await recoverParentSession(ctx.cwd, herdr, { sessionId: ownerSessionId, processId: process.pid }, (message) => ctx.ui.notify(message, "error")); }
174
+ finally { dispatcher.resume(); }
175
+ });
176
+
177
+ pi.registerTool({
178
+ name: "herdr_subagents",
179
+ label: "Herdr Subagents",
180
+ description: "Run one focused task or an ordered bounded-concurrency batch in visible interactive Herdr Pi Agents. Foreground calls block while workers run concurrently; background batches return a run ID immediately and publish completion later. Complete results and Pi sessions stay in project-local records.",
181
+ promptSnippet: "Run focused work in visible Herdr subagents",
182
+ promptGuidelines: ["Call action=list once before the first execution. Submit one prompt or an ordered tasks list; use concurrency only for independent read-only tasks. Foreground calls block until the run settles; background calls return a run ID immediately."],
183
+ parameters: HerdrSubagentsParams,
184
+
185
+ async execute(_toolCallId, params: HerdrSubagentsToolParams, signal, onUpdate, ctx) {
186
+ const generation = sessionGeneration;
187
+ const runId = params.tasks ? randomUUID() : undefined;
188
+ const executionId = runId ?? params.runId ?? randomUUID();
189
+ const controller = new AbortController();
190
+ runControllers.set(executionId, controller);
191
+ const runSignal = AbortSignal.any([controller.signal, sessionController.signal, ...(!params.background && signal ? [signal] : [])]);
192
+ const executeRun = async () => {
193
+ const result = await execute(params, runSignal, (update) => {
194
+ const details = update.details as HerdrSubagentsBatchDetails | undefined;
195
+ if (details?.activity) fleet.sync(details);
196
+ const updateText = update.content.find((item) => item.type === "text")?.text;
197
+ const updateDetails = update.details as { warning?: string };
198
+ if (updateDetails.warning || updateText?.includes("cleanup failed") || (params.background && updateText?.includes("appears stalled"))) ctx.ui.notify(updateDetails.warning ?? updateText ?? "Subagent warning", "warning");
199
+ if (!params.background) onUpdate?.(update);
200
+ }, ctx, { agentsDirectory: packageAgentsDir, owner: { sessionId: ownerSessionId, processId: process.pid }, runId });
201
+ fleet.sync(result.details);
202
+ return result;
203
+ };
204
+ if (!params.tasks) {
205
+ const promise = executeRun();
206
+ inFlight.add(promise);
207
+ const result = await promise.finally(() => { inFlight.delete(promise); runControllers.delete(executionId); });
208
+ if (params.action === "respond") {
209
+ const details = result.details as { runId?: string; status?: string };
210
+ if (details.status === "blocked") fleet.sync(result.details);
211
+ else if (isSettledRunStatus(details.status)) {
212
+ fleet.clearRun(params.runId);
213
+ if (params.runId) dispatcher.release(params.runId);
214
+ }
215
+ }
216
+ return result;
217
+ }
218
+ if (!runId) throw new Error("Batch run ID was not allocated.");
219
+ if (params.background) await persistQueuedRun(ctx.cwd, runId, params, { sessionId: ownerSessionId, processId: process.pid });
220
+ const promise = dispatcher.submit(runId, async () => {
221
+ const result = await executeRun().finally(async () => { if (params.background) await removeQueuedRun(ctx.cwd, runId); });
222
+ const details = result.details as HerdrSubagentsBatchDetails;
223
+ fleet.setActive(details.status === "blocked" ? details : undefined);
224
+ if (fleet.active) {
225
+ dispatcher.retain(runId);
226
+ }
227
+ return result;
228
+ });
229
+ inFlight.add(promise);
230
+ void promise.finally(() => { inFlight.delete(promise); runControllers.delete(executionId); }).catch(() => undefined);
231
+ if (params.background) {
232
+ void promise.then((result) => {
233
+ if (generation !== sessionGeneration) return;
234
+ notifyRun(runId, result);
235
+ if (!fleet.active && !dispatcher.snapshot().activeRunId && dispatcher.snapshot().queuedRunIds.length === 0) fleet.render();
236
+ }).catch((error) => {
237
+ if (generation !== sessionGeneration) return;
238
+ pi.sendMessage({ customType: "herdr-subagents-run", content: `Subagent run ${runId} failed: ${error instanceof Error ? error.message : String(error)}`, display: true, details: { runId, status: "failed" } }, { triggerTurn: true, deliverAs: "followUp" });
239
+ });
240
+ return { content: [{ type: "text" as const, text: `Subagent run queued: ${runId}` }], details: { runId, status: "queued", background: true } };
241
+ }
242
+ const result = await promise;
243
+ if (!fleet.active && !dispatcher.snapshot().activeRunId && dispatcher.snapshot().queuedRunIds.length === 0) fleet.resetSelection();
244
+ return result;
245
+ },
246
+
247
+ renderCall: renderToolCall,
248
+ renderResult: renderToolResult,
249
+ });
250
+
251
+ pi.registerCommand("herdr-subagents", {
252
+ description: "Inspect effective subagent agents, models, and settings",
253
+ getArgumentCompletions: (prefix) => ["active", "history", "agents", "models", "settings", "focus "].filter((value) => value.startsWith(prefix)).map((value) => ({ value, label: value })),
254
+ handler: (args, ctx) => handleHerdrCommand(args, ctx, fleet),
255
+ });
256
+ }
257
+
258
+ export default registerHerdrSubagents;
@@ -0,0 +1,65 @@
1
+ import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
2
+ import { resolve } from "node:path";
3
+ import { StringEnum } from "@earendil-works/pi-ai";
4
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
5
+ import { Type } from "typebox";
6
+ import { resultPathForAttempt } from "../src/result-path.ts";
7
+
8
+ const ReportParams = Type.Object({
9
+ status: StringEnum(["completed", "needs-input", "failed"] as const, { description: "Current outcome for this task." }),
10
+ summary: Type.String({ minLength: 1, description: "One concise paragraph for the parent agent." }),
11
+ result: Type.String({ description: "Complete final result. Stored on disk and excluded from the parent context." }),
12
+ documents: Type.Optional(Type.Array(Type.Object({
13
+ path: Type.String({ minLength: 1, description: "Path to a document produced or consulted by this task." }),
14
+ description: Type.String({ minLength: 1, description: "Short description of the document." }),
15
+ }))),
16
+ error: Type.Optional(Type.String({ minLength: 1, description: "Failure detail when status is failed." })),
17
+ question: Type.Optional(Type.String({ minLength: 1, description: "Exact question when status is needs-input." })),
18
+ });
19
+
20
+ function writeAtomic(path: string, content: string): void {
21
+ const temporary = `${path}.tmp-${process.pid}`;
22
+ writeFileSync(temporary, content, { encoding: "utf8", mode: 0o600 });
23
+ renameSync(temporary, path);
24
+ }
25
+
26
+ export default function (pi: ExtensionAPI) {
27
+ pi.registerTool({
28
+ name: "subagent_report",
29
+ label: "Subagent Report",
30
+ description: "Finish this delegated task by saving its complete result and returning only a compact structured report to the parent.",
31
+ parameters: ReportParams,
32
+ async execute(_toolCallId, params) {
33
+ const taskDirectory = process.env.PI_HERDR_SUBAGENTS_TASK_DIR?.trim();
34
+ if (!taskDirectory) throw new Error("PI_HERDR_SUBAGENTS_TASK_DIR is not set");
35
+ mkdirSync(taskDirectory, { recursive: true, mode: 0o700 });
36
+ let attempt = 1;
37
+ const task = JSON.parse(readFileSync(resolve(taskDirectory, "task.json"), "utf8")) as { attempt?: unknown };
38
+ if (Number.isInteger(task.attempt) && Number(task.attempt) > 1) attempt = Number(task.attempt);
39
+ const resultPath = resultPathForAttempt(taskDirectory, attempt);
40
+ if (attempt > 1) mkdirSync(resolve(taskDirectory, "turns"), { recursive: true, mode: 0o700 });
41
+ writeAtomic(resultPath, params.result);
42
+ const documents = (params.documents ?? []).map((document) => ({
43
+ path: resolve(process.cwd(), document.path),
44
+ description: document.description,
45
+ }));
46
+ if (!documents.some((document) => document.path === resultPath)) {
47
+ documents.unshift({ path: resultPath, description: "Complete task result" });
48
+ }
49
+ const report = {
50
+ status: params.status,
51
+ summary: params.summary,
52
+ documents,
53
+ ...(params.error ? { error: params.error } : {}),
54
+ ...(params.question ? { question: params.question } : {}),
55
+ reportedAt: new Date().toISOString(),
56
+ };
57
+ writeAtomic(resolve(taskDirectory, "report.json"), `${JSON.stringify(report, null, 2)}\n`);
58
+ return {
59
+ content: [{ type: "text" as const, text: `${params.status}: ${params.summary}` }],
60
+ details: report,
61
+ terminate: true,
62
+ };
63
+ },
64
+ });
65
+ }
@@ -1,7 +1,7 @@
1
1
  {
2
- "name": "@maxiaochao/pi-tiny-subagent",
3
- "version": "0.1.1",
4
- "description": "A tiny Pi subagent tool with package-local personas and detailed process errors.",
2
+ "name": "@maxiaochao/pi-herdr-subagents",
3
+ "version": "0.2.1",
4
+ "description": "Run inspectable interactive Pi subagent batches through Herdr.",
5
5
  "type": "module",
6
6
  "keywords": [
7
7
  "pi-package",
@@ -12,6 +12,7 @@
12
12
  "extensions",
13
13
  "src",
14
14
  "agents",
15
+ "tests",
15
16
  "docs",
16
17
  "README.md",
17
18
  "TESTING.md",
@@ -21,11 +22,11 @@
21
22
  "repository": {
22
23
  "type": "git",
23
24
  "url": "git+https://github.com/chaochaoxiaochao/pi-toolkit.git",
24
- "directory": "packages/tiny-subagent"
25
+ "directory": "packages/herdr-subagents"
25
26
  },
26
27
  "pi": {
27
28
  "extensions": [
28
- "./extensions/tiny-subagent.ts"
29
+ "./extensions/herdr-subagents.ts"
29
30
  ]
30
31
  },
31
32
  "peerDependencies": {
@@ -36,7 +37,7 @@
36
37
  },
37
38
  "scripts": {
38
39
  "test": "node --experimental-strip-types tests/run-tests.mjs",
39
- "load-test": "pi -ne -ns --no-session -e ./extensions/tiny-subagent.ts"
40
+ "load-test": "env -u PI_HERDR_SUBAGENTS_CHILD pi -ne -ns --no-session -e ./tests/extension-contract.ts && env -u PI_HERDR_SUBAGENTS_CHILD pi -ne -ns --no-session -e ./extensions/herdr-subagents.ts"
40
41
  },
41
42
  "publishConfig": {
42
43
  "access": "public"
@@ -0,0 +1,219 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { mkdir, writeFile } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ import { CliHerdrAutomation, type HerdrAutomation } from "./herdr.ts";
5
+ import type { PersonaAccess } from "./config.ts";
6
+ import type { HerdrSubagentsDocument } from "./runner.ts";
7
+ import { ensureRuntimeIgnored } from "./history.ts";
8
+ import { liveAgentName, promptLiveAgent } from "./live-agent.ts";
9
+ import { writeJsonAtomic } from "./state.ts";
10
+ import type { OwnerIdentity } from "./ownership.ts";
11
+ import { ownerRecord } from "./ownership.ts";
12
+ import { RunPaneAllocator } from "./run-pane-allocator.ts";
13
+ import { applyReport, applyTaskStatus, settleAttemptFailure, type PersistedReport, type RunRecord, type TaskRecord, type TaskStatus } from "./records.ts";
14
+ import { reportProtocolPrompt } from "./protocol.ts";
15
+ import { cleanupRunTab } from "./tab-cleanup.ts";
16
+ import { applyRunStatus } from "./run-status.ts";
17
+ import { isAbortError } from "./errors.ts";
18
+
19
+ export interface BatchTask {
20
+ name: string;
21
+ prompt: string;
22
+ agent: string;
23
+ access: PersonaAccess;
24
+ model?: string;
25
+ thinking?: string;
26
+ tools?: string[];
27
+ skills: string[];
28
+ systemPrompt: string;
29
+ }
30
+
31
+ export interface BatchOptions {
32
+ runId?: string;
33
+ label: string;
34
+ concurrency: number;
35
+ maxConcurrency: number;
36
+ stalledWarningSeconds?: number;
37
+ cwd: string;
38
+ signal?: AbortSignal;
39
+ herdr?: HerdrAutomation;
40
+ owner?: OwnerIdentity;
41
+ onUpdate?: (result: BatchResult) => void;
42
+ onCleanupError?: (message: string) => void;
43
+ }
44
+
45
+ type BatchTaskStatus = Exclude<TaskStatus, "starting">;
46
+ export interface BatchTaskResult {
47
+ index: number;
48
+ name: string;
49
+ status: Exclude<BatchTaskStatus, "queued" | "running">;
50
+ summary: string;
51
+ documents: HerdrSubagentsDocument[];
52
+ error?: string;
53
+ question?: string;
54
+ paneId: string;
55
+ recordDirectory: string;
56
+ sessionFile: string;
57
+ }
58
+ export interface BatchResult {
59
+ ok: boolean;
60
+ status: "starting" | "running" | "blocked" | "completed" | "partial" | "failed" | "cancelled";
61
+ runId: string;
62
+ label: string;
63
+ requestedConcurrency: number;
64
+ effectiveConcurrency: number;
65
+ tasks: BatchTaskResult[];
66
+ activity: Array<{ index: number; name: string; status: BatchTaskStatus; paneId?: string }>;
67
+ summary: string;
68
+ documents: HerdrSubagentsDocument[];
69
+ recordDirectory: string;
70
+ tabId?: string;
71
+ }
72
+
73
+ type TaskContext = {
74
+ taskId: string;
75
+ taskDirectory: string;
76
+ sessionFile: string;
77
+ reportFile: string;
78
+ taskFile: string;
79
+ systemPromptFile: string;
80
+ taskRecord: TaskRecord;
81
+ };
82
+
83
+ function timestamp(): string { return new Date().toISOString(); }
84
+ export async function runHerdrSubagentsBatch(tasks: BatchTask[], options: BatchOptions): Promise<BatchResult> {
85
+ if (!tasks.length) throw new Error("At least one task is required.");
86
+ if (process.env.HERDR_ENV !== "1" || !process.env.HERDR_WORKSPACE_ID) throw new Error("Herdr Subagents must run from a Pi session inside Herdr.");
87
+ const runId = options.runId ?? randomUUID();
88
+ const requestedConcurrency = Math.max(1, Math.floor(options.concurrency));
89
+ const effectiveConcurrency = tasks.some((task) => task.access === "write") ? 1 : Math.min(requestedConcurrency, options.maxConcurrency, tasks.length);
90
+ const runDirectory = join(options.cwd, ".pi", "herdr-subagents", "runs", `${timestamp().replace(/[-:.]/g, "").replace("Z", "Z-")}${runId.slice(0, 8)}`);
91
+ const runFile = join(runDirectory, "run.json");
92
+ await ensureRuntimeIgnored(options.cwd);
93
+ await mkdir(join(runDirectory, "tasks"), { recursive: true, mode: 0o700 });
94
+ const contexts: TaskContext[] = await Promise.all(tasks.map(async (task, index) => {
95
+ const taskId = randomUUID();
96
+ const taskDirectory = join(runDirectory, "tasks", `${String(index + 1).padStart(2, "0")}-${taskId.slice(0, 8)}`);
97
+ const sessionFile = join(taskDirectory, "session.jsonl");
98
+ const systemPromptFile = join(taskDirectory, "system-prompt.md");
99
+ const taskRecord: TaskRecord = {
100
+ id: taskId, runId, order: index + 1, name: task.name, status: "queued", prompt: task.prompt,
101
+ agent: task.agent, access: task.access, model: task.model, thinking: task.thinking,
102
+ tools: task.tools, skills: task.skills, systemPrompt: reportProtocolPrompt(task.systemPrompt), sessionFile,
103
+ };
104
+ await mkdir(taskDirectory, { recursive: true, mode: 0o700 });
105
+ await writeFile(systemPromptFile, String(taskRecord.systemPrompt), { encoding: "utf8", mode: 0o600 });
106
+ const taskFile = join(taskDirectory, "task.json");
107
+ await writeJsonAtomic(taskFile, taskRecord);
108
+ return { taskId, taskDirectory, sessionFile, reportFile: join(taskDirectory, "report.json"), taskFile, systemPromptFile, taskRecord };
109
+ }));
110
+ const runRecord: RunRecord = {
111
+ id: runId, label: options.label, status: "starting", cwd: options.cwd, ...ownerRecord(options.owner), requestedConcurrency,
112
+ effectiveConcurrency, stalledWarningSeconds: options.stalledWarningSeconds, startedAt: timestamp(), taskIds: contexts.map((context) => context.taskId), paneIds: [],
113
+ };
114
+ await writeJsonAtomic(runFile, runRecord);
115
+ const herdr = options.herdr ?? new CliHerdrAutomation();
116
+ const results = new Array<BatchTaskResult>(tasks.length);
117
+ const activity: BatchResult["activity"] = tasks.map((task, index) => ({ index, name: task.name, status: "queued" }));
118
+ let tabId: string | undefined;
119
+ const paneAllocator = new RunPaneAllocator({ herdr, run: runRecord, runFile, signal: options.signal, onCleanupError: options.onCleanupError });
120
+ const emit = (status: BatchResult["status"], summary: string) => options.onUpdate?.({
121
+ ok: false, status, runId, label: options.label, requestedConcurrency, effectiveConcurrency,
122
+ tasks: results.filter(Boolean), activity: activity.map((entry) => ({ ...entry })), summary,
123
+ documents: results.filter(Boolean).flatMap((result) => result.documents), recordDirectory: runDirectory, tabId,
124
+ });
125
+ const allocatePane = async (index: number): Promise<string> => {
126
+ const paneId = await paneAllocator.allocate(contexts[index].taskDirectory);
127
+ tabId = paneAllocator.tabId;
128
+ return paneId;
129
+ };
130
+
131
+ emit("starting", `Starting up to ${effectiveConcurrency} interactive Pi Agent${effectiveConcurrency === 1 ? "" : "s"}...`);
132
+ let nextIndex = 0;
133
+ let settled = 0;
134
+ try {
135
+ const runOne = async (index: number): Promise<void> => {
136
+ const task = tasks[index];
137
+ const context = contexts[index];
138
+ let paneId = "";
139
+ let agentName = liveAgentName(context.taskId);
140
+ let report: PersistedReport;
141
+ let status: BatchTaskResult["status"];
142
+ try {
143
+ paneId = await allocatePane(index);
144
+ const paneLabel = `${String(index + 1).padStart(2, "0")} · ${task.name}`;
145
+ await herdr.renamePane(paneId, paneLabel, options.signal);
146
+ applyTaskStatus(context.taskRecord, "running");
147
+ Object.assign(context.taskRecord, { tabId, paneId, paneLabel, agentName, startedAt: timestamp() });
148
+ await writeJsonAtomic(context.taskFile, context.taskRecord);
149
+ activity[index] = { index, name: task.name, status: "running", paneId };
150
+ emit("running", `${settled}/${tasks.length} tasks settled.`);
151
+ const prompted = await promptLiveAgent({ herdr, task: { ...task, id: context.taskId, sessionFile: context.sessionFile }, paneId, prompt: task.prompt, systemPromptFile: context.systemPromptFile, agentName, signal: options.signal, stalledWarningMs: options.stalledWarningSeconds ? options.stalledWarningSeconds * 1000 : undefined, onStalled: () => emit("running", `${task.name} appears stalled; it is still running.`) });
152
+ agentName = prompted.agentName;
153
+ report = prompted.report;
154
+ status = applyReport(context.taskRecord, report);
155
+ } catch (error) {
156
+ const settled = await settleAttemptFailure(context.taskRecord, context.reportFile, {
157
+ cancelled: isAbortError(error, options.signal),
158
+ error: error instanceof Error ? error.message : String(error),
159
+ name: task.name,
160
+ });
161
+ report = settled.report;
162
+ status = settled.status;
163
+ }
164
+ Object.assign(context.taskRecord, { paneId, agentName });
165
+ await writeJsonAtomic(context.taskFile, context.taskRecord);
166
+ results[index] = { index, name: task.name, status, summary: report.summary, documents: report.documents, ...(report.error ? { error: report.error } : {}), ...(report.question ? { question: report.question } : {}), paneId, recordDirectory: context.taskDirectory, sessionFile: context.sessionFile };
167
+ activity[index] = { index, name: task.name, status, ...(paneId ? { paneId } : {}) };
168
+ settled += 1;
169
+ emit("running", `${settled}/${tasks.length} tasks settled.`);
170
+ };
171
+ const worker = async () => {
172
+ while (!options.signal?.aborted) {
173
+ const index = nextIndex++;
174
+ if (index >= tasks.length) return;
175
+ await runOne(index);
176
+ if (results[index]?.status === "blocked") return;
177
+ }
178
+ };
179
+ await Promise.all(Array.from({ length: effectiveConcurrency }, worker));
180
+ if (options.signal?.aborted) {
181
+ for (let index = 0; index < tasks.length; index += 1) {
182
+ if (activity[index].status !== "queued") continue;
183
+ activity[index].status = "cancelled";
184
+ Object.assign(contexts[index].taskRecord, { status: "cancelled", completedAt: timestamp(), error: "Parent Pi session closed." });
185
+ await writeJsonAtomic(contexts[index].taskFile, contexts[index].taskRecord);
186
+ }
187
+ }
188
+ const settledResults = results.filter(Boolean);
189
+ const blocked = settledResults.find((result) => result.status === "blocked");
190
+ const failed = settledResults.filter((result) => result.status === "failed").length;
191
+ const cancelled = options.signal?.aborted || activity.some((entry) => entry.status === "cancelled");
192
+ const status: BatchResult["status"] = cancelled ? "cancelled" : blocked || activity.some((entry) => entry.status === "queued") ? "blocked" : failed === 0 ? "completed" : failed === tasks.length ? "failed" : "partial";
193
+ const summary = status === "cancelled" ? "Subagent batch cancelled because the parent Pi session closed." : blocked ? `${blocked.name} needs input: ${blocked.question}` : failed ? `${tasks.length - failed}/${tasks.length} tasks completed; ${failed} failed.` : `${tasks.length}/${tasks.length} tasks completed.`;
194
+ applyRunStatus(runRecord, status, { question: blocked?.question });
195
+ await writeJsonAtomic(runFile, runRecord);
196
+ return { ok: status === "completed", status, runId, label: options.label, requestedConcurrency, effectiveConcurrency, tasks: settledResults, activity, summary, documents: settledResults.flatMap((result) => result.documents), recordDirectory: runDirectory, tabId };
197
+ } catch (error) {
198
+ applyRunStatus(runRecord, options.signal?.aborted ? "cancelled" : "failed", { error: error instanceof Error ? error.message : String(error) });
199
+ await writeJsonAtomic(runFile, runRecord);
200
+ throw error;
201
+ } finally {
202
+ const keepTab = !options.signal?.aborted && activity.some((entry) => entry.status === "blocked" || entry.status === "queued" || entry.status === "running");
203
+ if (tabId && !keepTab) {
204
+ try {
205
+ await cleanupRunTab({
206
+ herdr, tabId, runFile, runRecord, signal: options.signal, failureStatus: "failed",
207
+ errorPrefix: "Task results settled, but Herdr tab cleanup failed",
208
+ onError: (message) => { emit("failed", message); options.onCleanupError?.(message); },
209
+ });
210
+ } catch (error) {
211
+ const message = error instanceof Error ? error.message : String(error);
212
+ const cleanupError = new Error(`Task results settled, but Herdr tab cleanup failed: ${message}`) as Error & { recordDirectory: string; runId: string };
213
+ cleanupError.recordDirectory = runDirectory;
214
+ cleanupError.runId = runId;
215
+ throw cleanupError;
216
+ }
217
+ }
218
+ }
219
+ }