@maxiaochao/pi-toolkit 0.9.4 → 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 (80) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/README.md +1 -1
  3. package/package.json +5 -5
  4. package/packages/codex-edit/CHANGELOG.md +15 -0
  5. package/packages/codex-edit/README.md +10 -3
  6. package/packages/codex-edit/TESTING.md +40 -9
  7. package/packages/codex-edit/benchmark/explicit-edit-100.json +2824 -0
  8. package/packages/codex-edit/benchmark/export-evidence.mjs +77 -0
  9. package/packages/codex-edit/benchmark/incident-cases.mjs +37 -0
  10. package/packages/codex-edit/benchmark/prepare.mjs +181 -0
  11. package/packages/codex-edit/benchmark/results/explicit-edit-100-sol-low-v3.json +4562 -0
  12. package/packages/codex-edit/benchmark/run-with-budget.mjs +77 -0
  13. package/packages/codex-edit/benchmark/schedule.mjs +19 -0
  14. package/packages/codex-edit/benchmark/select-tasks.mjs +167 -0
  15. package/packages/codex-edit/codex-edit-explainer.html +4 -4
  16. package/packages/codex-edit/docs/test-report.html +181 -0
  17. package/packages/codex-edit/docs/test-report.md +242 -0
  18. package/packages/codex-edit/docs/test-summary.md +9 -0
  19. package/packages/codex-edit/extensions/codex-edit.ts +4 -2
  20. package/packages/codex-edit/package.json +8 -2
  21. package/packages/codex-edit/src/parser.ts +26 -3
  22. package/packages/codex-edit/tests/benchmark.test.mjs +81 -0
  23. package/packages/codex-edit/tests/comparison-cases.mjs +396 -0
  24. package/packages/codex-edit/tests/comparison-harness.mjs +44 -0
  25. package/packages/codex-edit/tests/generate-test-report.mjs +589 -0
  26. package/packages/codex-edit/tests/legacy-parser-v0.1.7.mjs +128 -0
  27. package/packages/codex-edit/tests/native-vs-codex.ts +109 -0
  28. package/packages/codex-edit/tests/package.test.mjs +8 -0
  29. package/packages/codex-edit/tests/parser.test.mjs +123 -1
  30. package/packages/codex-edit/tests/released-vs-local.test.mjs +31 -0
  31. package/packages/herdr-subagents/CHANGELOG.md +41 -0
  32. package/packages/herdr-subagents/README.md +155 -0
  33. package/packages/herdr-subagents/TESTING.md +24 -0
  34. package/packages/herdr-subagents/agents/explorer.md +8 -0
  35. package/packages/herdr-subagents/agents/reviewer.md +8 -0
  36. package/packages/{tiny-subagent → herdr-subagents}/agents/worker.md +1 -0
  37. package/packages/herdr-subagents/docs/demo.gif +0 -0
  38. package/packages/herdr-subagents/docs/screenshot.png +0 -0
  39. package/packages/herdr-subagents/extensions/herdr-subagents.ts +258 -0
  40. package/packages/herdr-subagents/extensions/subagent-report.ts +65 -0
  41. package/packages/{tiny-subagent → herdr-subagents}/package.json +7 -6
  42. package/packages/herdr-subagents/src/batch-runner.ts +219 -0
  43. package/packages/herdr-subagents/src/blocking.ts +175 -0
  44. package/packages/herdr-subagents/src/config.ts +125 -0
  45. package/packages/herdr-subagents/src/continue-run.ts +87 -0
  46. package/packages/herdr-subagents/src/dispatcher.ts +73 -0
  47. package/packages/herdr-subagents/src/errors.ts +3 -0
  48. package/packages/herdr-subagents/src/fleet-controller.ts +82 -0
  49. package/packages/herdr-subagents/src/fleet.ts +56 -0
  50. package/packages/herdr-subagents/src/herdr.ts +224 -0
  51. package/packages/herdr-subagents/src/history.ts +210 -0
  52. package/packages/herdr-subagents/src/live-agent.ts +87 -0
  53. package/packages/herdr-subagents/src/monitor.ts +18 -0
  54. package/packages/herdr-subagents/src/ownership.ts +17 -0
  55. package/packages/herdr-subagents/src/parameters.ts +21 -0
  56. package/packages/{tiny-subagent → herdr-subagents}/src/personas.ts +13 -2
  57. package/packages/herdr-subagents/src/protocol.ts +23 -0
  58. package/packages/herdr-subagents/src/queued-runs.ts +70 -0
  59. package/packages/herdr-subagents/src/reconcile.ts +167 -0
  60. package/packages/herdr-subagents/src/records.ts +213 -0
  61. package/packages/herdr-subagents/src/result-path.ts +7 -0
  62. package/packages/herdr-subagents/src/run-locator.ts +19 -0
  63. package/packages/herdr-subagents/src/run-pane-allocator.ts +78 -0
  64. package/packages/herdr-subagents/src/run-status.ts +23 -0
  65. package/packages/herdr-subagents/src/runner.ts +250 -0
  66. package/packages/herdr-subagents/src/state.ts +11 -0
  67. package/packages/herdr-subagents/src/tab-cleanup.ts +68 -0
  68. package/packages/herdr-subagents/src/tool.ts +217 -0
  69. package/packages/herdr-subagents/src/validation.ts +38 -0
  70. package/packages/herdr-subagents/tests/extension-contract.ts +225 -0
  71. package/packages/herdr-subagents/tests/run-tests.mjs +889 -0
  72. package/packages/todo/extensions/todo.ts +1 -1
  73. package/packages/tiny-subagent/CHANGELOG.md +0 -12
  74. package/packages/tiny-subagent/README.md +0 -128
  75. package/packages/tiny-subagent/TESTING.md +0 -20
  76. package/packages/tiny-subagent/docs/demo.gif +0 -0
  77. package/packages/tiny-subagent/docs/screenshot.png +0 -0
  78. package/packages/tiny-subagent/extensions/tiny-subagent.ts +0 -228
  79. package/packages/tiny-subagent/src/runner.ts +0 -319
  80. package/packages/tiny-subagent/tests/run-tests.mjs +0 -111
@@ -0,0 +1,224 @@
1
+ import { spawn } from "node:child_process";
2
+
3
+ export interface CreateTabRequest {
4
+ workspaceId: string;
5
+ cwd: string;
6
+ label: string;
7
+ env: Record<string, string>;
8
+ focus: boolean;
9
+ signal?: AbortSignal;
10
+ }
11
+
12
+ export interface StartAgentRequest {
13
+ name: string;
14
+ kind: "pi";
15
+ paneId: string;
16
+ args: string[];
17
+ signal?: AbortSignal;
18
+ }
19
+
20
+ export interface PromptAgentRequest {
21
+ target: string;
22
+ prompt: string;
23
+ signal?: AbortSignal;
24
+ }
25
+
26
+ export interface SplitPaneRequest {
27
+ paneId: string;
28
+ cwd: string;
29
+ direction: "right" | "down";
30
+ focus: boolean;
31
+ env?: Record<string, string>;
32
+ signal?: AbortSignal;
33
+ }
34
+
35
+ export interface HerdrAutomation {
36
+ createTab(request: CreateTabRequest): Promise<{ tabId: string; paneId: string }>;
37
+ startAgent(request: StartAgentRequest): Promise<void>;
38
+ promptAgent(request: PromptAgentRequest): Promise<{ status?: string }>;
39
+ splitPane(request: SplitPaneRequest): Promise<{ paneId: string }>;
40
+ renamePane(paneId: string, label: string, signal?: AbortSignal): Promise<void>;
41
+ renameTab(tabId: string, label: string, signal?: AbortSignal): Promise<void>;
42
+ focusPane(paneId: string, signal?: AbortSignal): Promise<void>;
43
+ isTabFocused(tabId: string, signal?: AbortSignal): Promise<boolean>;
44
+ waitForTabUnfocused(tabId: string, signal?: AbortSignal): Promise<void>;
45
+ paneExists(paneId: string, signal?: AbortSignal): Promise<boolean>;
46
+ isTaskRunning(paneId: string, signal?: AbortSignal): Promise<boolean>;
47
+ closeTab(tabId: string, signal?: AbortSignal): Promise<void>;
48
+ }
49
+
50
+ interface HerdrResponse {
51
+ result?: {
52
+ tab?: { tab_id?: string; focused?: boolean };
53
+ root_pane?: { pane_id?: string };
54
+ pane?: { pane_id?: string };
55
+ agent?: { status?: string };
56
+ process_info?: { foreground_processes?: Array<{ name?: string; argv?: string[] }> };
57
+ };
58
+ }
59
+
60
+ export class HerdrCommandError extends Error {
61
+ readonly args: string[];
62
+ readonly exitCode: number | null;
63
+ readonly stderr: string;
64
+
65
+ constructor(
66
+ message: string,
67
+ args: string[],
68
+ exitCode: number | null,
69
+ stderr: string,
70
+ ) {
71
+ super(message);
72
+ this.name = "HerdrCommandError";
73
+ this.args = args;
74
+ this.exitCode = exitCode;
75
+ this.stderr = stderr;
76
+ }
77
+ }
78
+
79
+ async function runCommand(binary: string, args: string[], signal?: AbortSignal): Promise<string> {
80
+ return await new Promise<string>((resolve, reject) => {
81
+ let stdout = "";
82
+ let stderr = "";
83
+ let spawnError: Error | undefined;
84
+ const child = spawn(binary, args, {
85
+ stdio: ["ignore", "pipe", "pipe"],
86
+ env: process.env,
87
+ shell: false,
88
+ });
89
+ child.stdout.on("data", (chunk: Buffer | string) => {
90
+ stdout += chunk.toString();
91
+ });
92
+ child.stderr.on("data", (chunk: Buffer | string) => {
93
+ stderr += chunk.toString();
94
+ });
95
+ child.once("error", (error) => {
96
+ spawnError = error;
97
+ });
98
+ const abort = () => child.kill("SIGTERM");
99
+ const removeAbortListener = () => signal?.removeEventListener("abort", abort);
100
+ child.once("close", (code) => {
101
+ removeAbortListener();
102
+ if (spawnError) {
103
+ reject(new HerdrCommandError(spawnError.message, args, 1, stderr));
104
+ return;
105
+ }
106
+ if (code !== 0) {
107
+ const detail = stderr.trim() || stdout.trim() || `exit code ${code}`;
108
+ reject(new HerdrCommandError(`Herdr command failed: ${detail}`, args, code, stderr));
109
+ return;
110
+ }
111
+ resolve(stdout);
112
+ });
113
+
114
+ if (signal?.aborted) abort();
115
+ else signal?.addEventListener("abort", abort, { once: true });
116
+ });
117
+ }
118
+
119
+ function parseResponse(output: string, operation: string): HerdrResponse {
120
+ try {
121
+ return JSON.parse(output) as HerdrResponse;
122
+ } catch {
123
+ throw new Error(`${operation} returned invalid JSON`);
124
+ }
125
+ }
126
+
127
+ export class CliHerdrAutomation implements HerdrAutomation {
128
+ private readonly binary: string;
129
+
130
+ constructor(binary = process.env.PI_HERDR_SUBAGENTS_HERDR_BINARY?.trim() || "herdr") {
131
+ this.binary = binary;
132
+ }
133
+
134
+ async createTab(request: CreateTabRequest): Promise<{ tabId: string; paneId: string }> {
135
+ const args = ["tab", "create", "--workspace", request.workspaceId, "--cwd", request.cwd, "--label", request.label];
136
+ for (const [key, value] of Object.entries(request.env).sort(([left], [right]) => left.localeCompare(right))) {
137
+ args.push("--env", `${key}=${value}`);
138
+ }
139
+ args.push(request.focus ? "--focus" : "--no-focus");
140
+ const response = parseResponse(await runCommand(this.binary, args, request.signal), "herdr tab create");
141
+ const tabId = response.result?.tab?.tab_id;
142
+ const paneId = response.result?.root_pane?.pane_id;
143
+ if (!tabId || !paneId) throw new Error("herdr tab create omitted the tab or root pane id");
144
+ return { tabId, paneId };
145
+ }
146
+
147
+ async startAgent(request: StartAgentRequest): Promise<void> {
148
+ await runCommand(this.binary, ["agent", "start", request.name, "--kind", request.kind, "--pane", request.paneId, "--", ...request.args], request.signal);
149
+ }
150
+
151
+ async promptAgent(request: PromptAgentRequest): Promise<{ status?: string }> {
152
+ const response = parseResponse(
153
+ await runCommand(this.binary, ["agent", "prompt", request.target, request.prompt, "--wait"], request.signal),
154
+ "herdr agent prompt",
155
+ );
156
+ return { status: response.result?.agent?.status };
157
+ }
158
+
159
+ async splitPane(request: SplitPaneRequest): Promise<{ paneId: string }> {
160
+ const args = ["pane", "split", request.paneId, "--direction", request.direction, "--cwd", request.cwd, request.focus ? "--focus" : "--no-focus"];
161
+ for (const [key, value] of Object.entries(request.env ?? {}).sort(([left], [right]) => left.localeCompare(right))) args.push("--env", `${key}=${value}`);
162
+ const response = parseResponse(await runCommand(this.binary, args, request.signal), "herdr pane split");
163
+ const paneId = response.result?.pane?.pane_id;
164
+ if (!paneId) throw new Error("herdr pane split omitted the pane id");
165
+ return { paneId };
166
+ }
167
+
168
+ async renamePane(paneId: string, label: string, signal?: AbortSignal): Promise<void> {
169
+ await runCommand(this.binary, ["pane", "rename", paneId, label], signal);
170
+ }
171
+
172
+ async renameTab(tabId: string, label: string, signal?: AbortSignal): Promise<void> {
173
+ await runCommand(this.binary, ["tab", "rename", tabId, label], signal);
174
+ }
175
+
176
+ async focusPane(paneId: string, signal?: AbortSignal): Promise<void> {
177
+ await runCommand(this.binary, ["agent", "focus", paneId], signal);
178
+ }
179
+
180
+ async isTabFocused(tabId: string, signal?: AbortSignal): Promise<boolean> {
181
+ const response = parseResponse(await runCommand(this.binary, ["tab", "get", tabId], signal), "herdr tab get");
182
+ return response.result?.tab?.focused === true;
183
+ }
184
+
185
+ async waitForTabUnfocused(tabId: string, signal?: AbortSignal): Promise<void> {
186
+ while (await this.isTabFocused(tabId, signal)) {
187
+ await new Promise<void>((resolve, reject) => {
188
+ const onAbort = () => { clearTimeout(timer); reject(signal?.reason); };
189
+ const timer = setTimeout(() => { signal?.removeEventListener("abort", onAbort); resolve(); }, 500);
190
+ if (signal?.aborted) onAbort();
191
+ else signal?.addEventListener("abort", onAbort, { once: true });
192
+ });
193
+ }
194
+ }
195
+
196
+ async paneExists(paneId: string, signal?: AbortSignal): Promise<boolean> {
197
+ try { await runCommand(this.binary, ["pane", "get", paneId], signal); return true; }
198
+ catch (error) {
199
+ if (error instanceof HerdrCommandError && /pane[_ -]?not[_ -]?found|unknown pane/i.test(`${error.message}\n${error.stderr}`)) return false;
200
+ throw error;
201
+ }
202
+ }
203
+
204
+ async isTaskRunning(paneId: string, signal?: AbortSignal): Promise<boolean> {
205
+ try {
206
+ const response = parseResponse(await runCommand(this.binary, ["pane", "process-info", "--pane", paneId], signal), "herdr pane process-info");
207
+ return (response.result?.process_info?.foreground_processes ?? []).some((process) => {
208
+ if (process.name === "pi") return true;
209
+ return (process.argv ?? []).some((argument) => {
210
+ const normalized = argument.replaceAll("\\", "/");
211
+ const file = normalized.split("/").at(-1);
212
+ return file === "pi" || (/pi-coding-agent/.test(normalized) && /(?:^|\/)cli\.js$/.test(normalized)) || /pi-coding-agent\/dist\/bundle\//.test(normalized);
213
+ });
214
+ });
215
+ } catch (error) {
216
+ if (error instanceof HerdrCommandError && /pane[_ -]?not[_ -]?found|unknown pane/i.test(`${error.message}\n${error.stderr}`)) return false;
217
+ throw error;
218
+ }
219
+ }
220
+
221
+ async closeTab(tabId: string, signal?: AbortSignal): Promise<void> {
222
+ await runCommand(this.binary, ["tab", "close", tabId], signal);
223
+ }
224
+ }
@@ -0,0 +1,210 @@
1
+ import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
2
+ import { appendFile, mkdir, readFile, rm, rmdir } from "node:fs/promises";
3
+ import { isAbsolute, join } from "node:path";
4
+ import { CliHerdrAutomation, type HerdrAutomation } from "./herdr.ts";
5
+ import { liveAgentName, promptLiveAgent } from "./live-agent.ts";
6
+ import { writeJsonAtomic, writeTextAtomic } from "./state.ts";
7
+ import { cleanupRunTab } from "./tab-cleanup.ts";
8
+ import type { OwnerIdentity } from "./ownership.ts";
9
+ import { ownerRecord } from "./ownership.ts";
10
+ import { aggregateTaskStatus, applyRunStatus } from "./run-status.ts";
11
+ import { applyReport, applyTaskStatus, isActiveStatus, readReport, readRunRecord, readTaskRecord, settleAttemptFailure, type PersistedDocument, type PersistedReport, type RunRecord, type RunStatus, type TaskRecord, type TaskStatus } from "./records.ts";
12
+ import { continuationSystemPrompt } from "./protocol.ts";
13
+ import { isAbortError } from "./errors.ts";
14
+ import { findRunDirectory } from "./run-locator.ts";
15
+
16
+ export interface HistoricalTask { order: number; id: string; name: string; agent: string; status: TaskStatus; summary?: string; question?: string; documents: PersistedDocument[]; recordDirectory: string; sessionFile: string; model?: string; thinking?: string; tools?: string[]; skills: string[]; access?: string; }
17
+ export interface HistoricalRun { id: string; label: string; status: RunStatus; startedAt: string; completedAt?: string; effectiveConcurrency?: number; cleanupPendingTabIds?: string[]; tasks: HistoricalTask[]; recordDirectory: string; }
18
+
19
+ export async function ensureRuntimeIgnored(cwd: string): Promise<void> {
20
+ const piDirectory = join(cwd, ".pi");
21
+ await mkdir(piDirectory, { recursive: true, mode: 0o700 });
22
+ const dotGit = join(cwd, ".git");
23
+ let ignoreFile = join(piDirectory, ".gitignore");
24
+ let line = "herdr-subagents/runs/";
25
+ if (existsSync(dotGit)) {
26
+ let gitDirectory = dotGit;
27
+ if (!statSync(dotGit).isDirectory()) {
28
+ const configured = readFileSync(dotGit, "utf8").match(/^gitdir:\s*(.+)$/m)?.[1]?.trim();
29
+ if (configured) gitDirectory = isAbsolute(configured) ? configured : join(cwd, configured);
30
+ }
31
+ const commonDirectoryFile = join(gitDirectory, "commondir");
32
+ const commonDirectory = existsSync(commonDirectoryFile) ? join(gitDirectory, readFileSync(commonDirectoryFile, "utf8").trim()) : gitDirectory;
33
+ ignoreFile = join(commonDirectory, "info", "exclude");
34
+ line = "/.pi/herdr-subagents/runs/";
35
+ await mkdir(join(commonDirectory, "info"), { recursive: true });
36
+ }
37
+ const current = existsSync(ignoreFile) ? await readFile(ignoreFile, "utf8") : "";
38
+ if (current.split(/\r?\n/).includes(line)) return;
39
+ await appendFile(ignoreFile, `${current && !current.endsWith("\n") ? "\n" : ""}${line}\n`, { encoding: "utf8", mode: 0o600 });
40
+ }
41
+
42
+ export async function listSubagentHistory(cwd: string): Promise<HistoricalRun[]> {
43
+ const runsDirectory = join(cwd, ".pi", "herdr-subagents", "runs");
44
+ if (!existsSync(runsDirectory)) return [];
45
+ const history: HistoricalRun[] = [];
46
+ for (const entry of readdirSync(runsDirectory).sort().reverse()) {
47
+ const directory = join(runsDirectory, entry);
48
+ try {
49
+ const run = await readRunRecord(join(directory, "run.json"));
50
+ const tasks: HistoricalTask[] = [];
51
+ let projectedStatus = run.status;
52
+ const tasksDirectory = join(directory, "tasks");
53
+ if (existsSync(tasksDirectory)) for (const taskEntry of readdirSync(tasksDirectory).sort()) {
54
+ const taskDirectory = join(tasksDirectory, taskEntry);
55
+ let task: TaskRecord;
56
+ try { task = await readTaskRecord(join(taskDirectory, "task.json")); }
57
+ catch (error) {
58
+ projectedStatus = "failed";
59
+ tasks.push({ order: tasks.length + 1, id: taskEntry, name: taskEntry, agent: "unknown", status: "failed", summary: `Invalid durable task: ${error instanceof Error ? error.message : String(error)}`, documents: [], recordDirectory: taskDirectory, sessionFile: join(taskDirectory, "session.jsonl"), skills: [] });
60
+ continue;
61
+ }
62
+ let report: { summary?: string; question?: string; documents: Array<{ path: string; description: string }> } = { documents: [] };
63
+ let historyStatus = task.status;
64
+ const reportFile = join(taskDirectory, "report.json");
65
+ if (existsSync(reportFile)) {
66
+ try { report = await readReport(reportFile); }
67
+ catch (error) {
68
+ historyStatus = "failed";
69
+ report = { summary: `Invalid durable report: ${error instanceof Error ? error.message : String(error)}`, documents: [] };
70
+ }
71
+ } else if (["completed", "failed", "blocked"].includes(task.status)) {
72
+ historyStatus = "failed";
73
+ report = { summary: `Missing structured report: ${reportFile}`, documents: [] };
74
+ }
75
+ tasks.push({ order: task.order ?? tasks.length + 1, id: task.id, name: task.name ?? task.agent ?? "worker", agent: task.agent ?? "worker", status: historyStatus, summary: report.summary, question: report.question, documents: report.documents ?? [], recordDirectory: taskDirectory, sessionFile: task.sessionFile, model: task.model, thinking: task.thinking, tools: task.tools, skills: task.skills ?? [], access: task.access });
76
+ }
77
+ history.push({ id: run.id, label: run.label, status: projectedStatus, startedAt: run.startedAt, completedAt: run.completedAt, effectiveConcurrency: run.effectiveConcurrency, cleanupPendingTabIds: run.cleanupPendingTabIds, tasks, recordDirectory: directory });
78
+ } catch (error) {
79
+ history.push({ id: entry, label: "Unreadable Subagent run", status: "failed", startedAt: "", tasks: [{ order: 1, id: `${entry}-invalid`, name: "Invalid durable run", agent: "unknown", status: "failed", summary: `Could not read Subagent history ${directory}: ${error instanceof Error ? error.message : String(error)}`, documents: [], recordDirectory: directory, sessionFile: join(directory, "session.jsonl"), skills: [] }], recordDirectory: directory });
80
+ }
81
+ }
82
+ return history;
83
+ }
84
+
85
+ export function historyText(history: HistoricalRun[]): string {
86
+ if (!history.length) return "No Subagent history in this project.";
87
+ return history.flatMap((run) => [`${run.id} · ${run.label} · ${run.status} · ${run.tasks.length} tasks`, ...run.tasks.map((task) => ` ${task.order}. ${task.name}: ${task.status}${task.summary ? ` — ${task.summary}` : ""}`)]).join("\n");
88
+ }
89
+
90
+ export async function cleanSubagentRun(cwd: string, runId: string): Promise<boolean> {
91
+ const run = (await listSubagentHistory(cwd)).find((candidate) => candidate.id === runId);
92
+ if (!run) return false;
93
+ if (isActiveStatus(run.status)) throw new Error(`Subagent run '${runId}' is still active and cannot be cleaned.`);
94
+ if (run.cleanupPendingTabIds?.length) throw new Error(`Subagent run '${runId}' is still waiting to clean up Herdr tabs ${run.cleanupPendingTabIds.join(", ")}.`);
95
+ await rm(run.recordDirectory, { recursive: true, force: false });
96
+ return true;
97
+ }
98
+
99
+ export interface ResumeHistoricalTaskOptions { herdr?: HerdrAutomation; signal?: AbortSignal; owner?: OwnerIdentity; onCleanupError?: (message: string) => void; stalledWarningSeconds?: number; onStalled?: (message: string) => void; }
100
+
101
+ export async function resumeHistoricalTask(cwd: string, runId: string, taskNumber: number, prompt: string, options: ResumeHistoricalTaskOptions = {}): Promise<{ runId: string; status: TaskStatus; summary: string; documents: PersistedDocument[]; tabId: string; paneId: string; sessionFile: string }> {
102
+ const runDirectory = await findRunDirectory(join(cwd, ".pi", "herdr-subagents", "runs"), runId);
103
+ if (!runDirectory) throw new Error(`Unknown Subagent run '${runId}'.`);
104
+ const lockDirectory = join(runDirectory, ".resume.lock");
105
+ try { await mkdir(lockDirectory); }
106
+ catch (error) {
107
+ if ((error as NodeJS.ErrnoException).code === "EEXIST") throw new Error(`Subagent run '${runId}' already has a historical resume in progress (lock: ${lockDirectory}).`);
108
+ throw error;
109
+ }
110
+ try { return await resumeHistoricalTaskLocked(cwd, runId, taskNumber, prompt, options); }
111
+ finally { await rmdir(lockDirectory); }
112
+ }
113
+
114
+ async function resumeHistoricalTaskLocked(cwd: string, runId: string, taskNumber: number, prompt: string, options: ResumeHistoricalTaskOptions): Promise<{ runId: string; status: TaskStatus; summary: string; documents: PersistedDocument[]; tabId: string; paneId: string; sessionFile: string }> {
115
+ const herdr = options.herdr ?? new CliHerdrAutomation();
116
+ const { signal, onCleanupError } = options;
117
+ const run = (await listSubagentHistory(cwd)).find((candidate) => candidate.id === runId);
118
+ const historical = run?.tasks.find((task) => task.order === taskNumber);
119
+ if (!run || !historical) throw new Error(`Unknown historical task ${runId}#${taskNumber}.`);
120
+ if (isActiveStatus(run.status)) throw new Error(`Subagent run '${runId}' is still active. Use action 'respond' for blocked work or wait for it to settle before resuming history.`);
121
+ if (run.cleanupPendingTabIds?.length) throw new Error(`Subagent run '${runId}' is still waiting to clean up Herdr tabs ${run.cleanupPendingTabIds.join(", ")}.`);
122
+ if (!existsSync(historical.sessionFile)) throw new Error(`Historical task ${runId}#${taskNumber} has no saved Pi session.`);
123
+ const taskFile = join(historical.recordDirectory, "task.json");
124
+ const task = await readTaskRecord(taskFile);
125
+ const attempt = Number(task.attempt ?? 1) + 1;
126
+ const turnsDirectory = join(historical.recordDirectory, "turns");
127
+ await mkdir(turnsDirectory, { recursive: true, mode: 0o700 });
128
+ const initialTurnFile = join(turnsDirectory, "01.json");
129
+ if (!existsSync(initialTurnFile)) {
130
+ const initialReportFile = join(historical.recordDirectory, "report.json");
131
+ let initialReport;
132
+ try { initialReport = await readReport(initialReportFile); }
133
+ catch (error) { initialReport = { status: "failed" as const, summary: `Previous structured report was unavailable: ${error instanceof Error ? error.message : String(error)}`, documents: [] }; }
134
+ await writeJsonAtomic(initialTurnFile, { attempt: 1, prompt: task.prompt, status: task.status, summary: initialReport.summary, question: initialReport.question, error: initialReport.error, documents: initialReport.documents ?? [], sessionFile: historical.sessionFile, startedAt: task.startedAt, completedAt: task.completedAt });
135
+ }
136
+ const turnFile = join(turnsDirectory, `${String(attempt).padStart(2, "0")}.json`);
137
+ const followupPrompt = join(historical.recordDirectory, "followup-system-prompt.md");
138
+ await writeTextAtomic(followupPrompt, `${continuationSystemPrompt(task.systemPrompt, "Continue the requested historical work.", "followup")}\n`);
139
+ let tab: { tabId: string; paneId: string } | undefined;
140
+ let failedAttempt: { status: "cancelled" | "failed"; report: PersistedReport; error: unknown } | undefined;
141
+ const agentName = liveAgentName(historical.id, attempt);
142
+ try {
143
+ let report: PersistedReport;
144
+ let status: "blocked" | "completed" | "failed";
145
+ try {
146
+ const env = { PI_HERDR_SUBAGENTS_CHILD: "1", PI_HERDR_SUBAGENTS_TASK_DIR: historical.recordDirectory };
147
+ tab = await herdr.createTab({ workspaceId: process.env.HERDR_WORKSPACE_ID ?? "", cwd, label: `SA · ${run.label}`, env, focus: false, signal: options.signal });
148
+ const paneLabel = `${String(historical.order).padStart(2, "0")} · ${historical.name}`;
149
+ await herdr.renamePane(tab.paneId, paneLabel, options.signal);
150
+ applyTaskStatus(task, "running");
151
+ Object.assign(task, { attempt, currentTurnFile: turnFile, tabId: tab.tabId, paneId: tab.paneId, paneLabel, agentName });
152
+ const activeRunFile = join(run.recordDirectory, "run.json");
153
+ const activeRunRecord = await readRunRecord(activeRunFile);
154
+ applyRunStatus(activeRunRecord, "running");
155
+ Object.assign(activeRunRecord, { ...ownerRecord(options.owner), tabId: tab.tabId, paneIds: [...new Set([...(activeRunRecord.paneIds ?? []), tab.paneId])] });
156
+ await Promise.all([
157
+ writeJsonAtomic(taskFile, task),
158
+ writeJsonAtomic(turnFile, { attempt, prompt, status: "running", sessionFile: historical.sessionFile, tabId: tab.tabId, paneId: tab.paneId, agentName, startedAt: new Date().toISOString() }),
159
+ writeJsonAtomic(activeRunFile, activeRunRecord),
160
+ ]);
161
+ report = (await promptLiveAgent({ herdr, task: { ...task, id: historical.id, sessionFile: historical.sessionFile }, paneId: tab.paneId, prompt, systemPromptFile: followupPrompt, agentName, signal, stalledWarningMs: options.stalledWarningSeconds ? options.stalledWarningSeconds * 1000 : undefined, onStalled: () => options.onStalled?.(`${historical.name} appears stalled; it is still running.`) })).report;
162
+ status = applyReport(task, report);
163
+ } catch (error) {
164
+ const settled = await settleAttemptFailure(task, join(historical.recordDirectory, "report.json"), {
165
+ cancelled: isAbortError(error, signal),
166
+ error: error instanceof Error ? error.message : String(error),
167
+ name: `${historical.name} follow-up`,
168
+ replaceReport: true,
169
+ });
170
+ failedAttempt = { ...settled, error };
171
+ throw error;
172
+ }
173
+ await Promise.all([
174
+ writeJsonAtomic(taskFile, task),
175
+ writeJsonAtomic(turnFile, { attempt, prompt, status, summary: report.summary, documents: report.documents ?? [], sessionFile: historical.sessionFile, tabId: tab.tabId, paneId: tab.paneId, agentName, completedAt: new Date().toISOString(), ...(report.question ? { question: report.question } : {}), ...(report.error ? { error: report.error } : {}) }),
176
+ ]);
177
+ const runRecord = await readRunRecord(join(run.recordDirectory, "run.json"));
178
+ const aggregateStatus = aggregateTaskStatus((await listSubagentHistory(cwd)).find((candidate) => candidate.id === runId)?.tasks.map((entry) => entry.status) ?? [status]);
179
+ applyRunStatus(runRecord, aggregateStatus, { question: report.question });
180
+ Object.assign(runRecord, { ...ownerRecord(options.owner), tabId: tab.tabId });
181
+ await writeJsonAtomic(join(run.recordDirectory, "run.json"), runRecord);
182
+ if (status !== "blocked") {
183
+ await cleanupRunTab({ herdr, tabId: tab.tabId, runFile: join(run.recordDirectory, "run.json"), runRecord, signal, onError: onCleanupError, failureStatus: "failed" });
184
+ }
185
+ return { runId, status, summary: report.summary, documents: report.documents ?? [], tabId: tab.tabId, paneId: tab.paneId, sessionFile: historical.sessionFile };
186
+ } catch (error) {
187
+ const message = error instanceof Error ? error.message : String(error);
188
+ const completedAt = new Date().toISOString();
189
+ const settled = failedAttempt ?? await settleAttemptFailure(task, join(historical.recordDirectory, "report.json"), {
190
+ cancelled: isAbortError(error, signal),
191
+ error: message,
192
+ name: `${historical.name} follow-up`,
193
+ at: completedAt,
194
+ replaceReport: true,
195
+ });
196
+ const { status, report } = settled;
197
+ task.attempt = attempt;
198
+ const runRecord = await readRunRecord(join(run.recordDirectory, "run.json"));
199
+ const aggregateStatus = status === "cancelled" ? "cancelled" : aggregateTaskStatus(run.tasks.map((entry) => entry.id === historical.id ? status : entry.status));
200
+ applyRunStatus(runRecord, aggregateStatus, { error: message });
201
+ Object.assign(runRecord, { ...ownerRecord(options.owner), ...(tab ? { tabId: tab.tabId } : {}) });
202
+ await Promise.all([
203
+ writeJsonAtomic(taskFile, task),
204
+ writeJsonAtomic(turnFile, { attempt, prompt, status, summary: report.summary, documents: report.documents, error: message, sessionFile: historical.sessionFile, ...(tab ? { tabId: tab.tabId, paneId: tab.paneId } : {}), completedAt }),
205
+ writeJsonAtomic(join(run.recordDirectory, "run.json"), runRecord),
206
+ ]);
207
+ if (tab) await cleanupRunTab({ herdr, tabId: tab.tabId, runFile: join(run.recordDirectory, "run.json"), runRecord, signal, onError: onCleanupError, failureStatus: "failed" });
208
+ throw error;
209
+ }
210
+ }
@@ -0,0 +1,87 @@
1
+ import { existsSync } from "node:fs";
2
+ import { readFile, rm } from "node:fs/promises";
3
+ import { dirname, join } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import type { HerdrAutomation } from "./herdr.ts";
6
+ import type { HerdrSubagentsReport } from "./runner.ts";
7
+ import { parseReport } from "./records.ts";
8
+
9
+ export interface LiveTaskRecord {
10
+ id: string;
11
+ name?: string;
12
+ agent?: string;
13
+ sessionFile: string;
14
+ model?: string;
15
+ thinking?: string;
16
+ tools?: string[];
17
+ skills?: string[];
18
+ }
19
+
20
+ export function childPiArgs(task: LiveTaskRecord, systemPromptFile: string): string[] {
21
+ const args = [
22
+ "--approve",
23
+ "--session", task.sessionFile,
24
+ "--name", `Subagent: ${task.name ?? task.agent ?? "worker"}`,
25
+ "--no-extensions",
26
+ "--extension", fileURLToPath(new URL("../extensions/subagent-report.ts", import.meta.url)),
27
+ "--no-skills",
28
+ "--append-system-prompt", systemPromptFile,
29
+ ];
30
+ if (task.model) args.push("--model", task.model);
31
+ if (task.thinking) args.push("--thinking", task.thinking);
32
+ if (task.tools?.length) args.push("--tools", [...new Set([...task.tools, "subagent_report"])].join(","));
33
+ for (const skill of task.skills ?? []) args.push("--skill", skill);
34
+ return args;
35
+ }
36
+
37
+ export function liveAgentName(taskId: string, attempt = 1): string {
38
+ return `herdr-subagent-${taskId.replace(/[^a-zA-Z0-9]/g, "").slice(0, 12)}-${attempt}`;
39
+ }
40
+
41
+ export async function promptLiveAgent(options: {
42
+ herdr: HerdrAutomation;
43
+ task: LiveTaskRecord;
44
+ paneId: string;
45
+ prompt: string;
46
+ systemPromptFile: string;
47
+ agentName?: string;
48
+ attempt?: number;
49
+ signal?: AbortSignal;
50
+ start?: boolean;
51
+ stalledWarningMs?: number;
52
+ onStalled?: () => void;
53
+ }): Promise<{ report: HerdrSubagentsReport; agentName: string }> {
54
+ const taskDirectory = dirname(options.task.sessionFile);
55
+ const reportFile = join(taskDirectory, "report.json");
56
+ await rm(reportFile, { force: true });
57
+ const agentName = options.agentName ?? liveAgentName(options.task.id, options.attempt);
58
+ if (options.start !== false) {
59
+ await options.herdr.startAgent({
60
+ name: agentName,
61
+ kind: "pi",
62
+ paneId: options.paneId,
63
+ args: childPiArgs(options.task, options.systemPromptFile),
64
+ signal: options.signal,
65
+ });
66
+ }
67
+ const stalledTimer = options.stalledWarningMs ? setTimeout(() => options.onStalled?.(), options.stalledWarningMs) : undefined;
68
+ let promptError: unknown;
69
+ try { await options.herdr.promptAgent({ target: agentName, prompt: options.prompt, signal: options.signal }); }
70
+ catch (error) { promptError = error; }
71
+ finally { if (stalledTimer) clearTimeout(stalledTimer); }
72
+ if (!existsSync(reportFile)) {
73
+ if (promptError) throw promptError;
74
+ throw new Error("Child Pi settled but did not submit a subagent_report.");
75
+ }
76
+ let value: unknown;
77
+ try { value = JSON.parse(await readFile(reportFile, "utf8")); }
78
+ catch {
79
+ if (promptError) throw new Error(`${promptError instanceof Error ? promptError.message : String(promptError)}; Child Pi also submitted an invalid subagent_report.`);
80
+ throw new Error("Child Pi submitted an invalid subagent_report.");
81
+ }
82
+ try { return { report: parseReport(value, "child subagent_report") as HerdrSubagentsReport, agentName }; }
83
+ catch (error) {
84
+ const detail = `Child Pi submitted an invalid subagent_report: ${error instanceof Error ? error.message : String(error)}`;
85
+ throw new Error(promptError ? `${promptError instanceof Error ? promptError.message : String(promptError)}; ${detail}` : detail);
86
+ }
87
+ }
@@ -0,0 +1,18 @@
1
+ import type { BatchResult } from "./batch-runner.ts";
2
+ import type { HerdrAutomation } from "./herdr.ts";
3
+
4
+ export function activityCounts(run: Pick<BatchResult, "activity">): Record<"running" | "queued" | "blocked" | "failed" | "completed", number> {
5
+ const count = (status: string) => run.activity.filter((task) => task.status === status).length;
6
+ return { running: count("running"), queued: count("queued"), blocked: count("blocked"), failed: count("failed"), completed: count("completed") };
7
+ }
8
+
9
+ export function activeRunText(run: Pick<BatchResult, "label" | "runId" | "activity">): string {
10
+ return [`${run.label} (${run.runId})`, ...run.activity.map((task) => `${task.index + 1}. ${task.name}: ${task.status}`)].join("\n");
11
+ }
12
+
13
+ export async function focusActiveTask(run: Pick<BatchResult, "activity"> | undefined, taskNumber: number, herdr: HerdrAutomation): Promise<boolean> {
14
+ const task = run?.activity.find((candidate) => candidate.index === taskNumber - 1);
15
+ if (!task?.paneId) return false;
16
+ await herdr.focusPane(task.paneId);
17
+ return true;
18
+ }
@@ -0,0 +1,17 @@
1
+ export interface OwnerIdentity {
2
+ sessionId?: string;
3
+ processId?: number;
4
+ }
5
+
6
+ export function ownerRecord(owner?: OwnerIdentity): { ownerSessionId?: string; ownerProcessId?: number } {
7
+ return {
8
+ ...(owner?.sessionId ? { ownerSessionId: owner.sessionId } : {}),
9
+ ...(owner?.processId ? { ownerProcessId: owner.processId } : {}),
10
+ };
11
+ }
12
+
13
+ export function isProcessAlive(pid: unknown): boolean {
14
+ if (!Number.isInteger(pid) || Number(pid) <= 0) return false;
15
+ try { process.kill(Number(pid), 0); return true; }
16
+ catch { return false; }
17
+ }
@@ -0,0 +1,21 @@
1
+ import { StringEnum } from "@earendil-works/pi-ai";
2
+ import { Type } from "typebox";
3
+
4
+ export const HerdrSubagentsParams = Type.Object({
5
+ action: Type.Optional(StringEnum(["list", "respond", "history", "resume", "cleanup"] as const, { description: "Operation mode. Omit it to execute one task or a batch." })),
6
+ runId: Type.Optional(Type.String({ minLength: 1, description: "Durable run ID used by respond, resume, or cleanup." })),
7
+ answer: Type.Optional(Type.String({ minLength: 1, description: "Answer sent to a blocked task when action is respond." })),
8
+ task: Type.Optional(Type.Integer({ minimum: 1, description: "One-based task number selected for respond or resume." })),
9
+ prompt: Type.Optional(Type.String({ minLength: 1, description: "Task prompt for single execution, or follow-up prompt for resume." })),
10
+ agent: Type.Optional(Type.String({ minLength: 1, description: "Persona for single execution. Defaults to worker." })),
11
+ label: Type.Optional(Type.String({ minLength: 1, maxLength: 48, description: "Short Herdr run-tab label." })),
12
+ model: Type.Optional(Type.String({ minLength: 1, description: "Optional Pi model override, for example provider/model:high." })),
13
+ tasks: Type.Optional(Type.Array(Type.Object({
14
+ name: Type.String({ minLength: 1, maxLength: 48, description: "Short task name shown in the pane and task list." }),
15
+ prompt: Type.String({ minLength: 1, description: "Self-contained prompt for this task." }),
16
+ agent: Type.Optional(Type.String({ minLength: 1, description: "Persona for this task. Defaults to worker." })),
17
+ model: Type.Optional(Type.String({ minLength: 1, description: "Optional model override for this task." })),
18
+ }), { minItems: 1, description: "Ordered tasks for bounded-concurrency batch execution." })),
19
+ concurrency: Type.Optional(Type.Integer({ minimum: 1, description: "Maximum simultaneously active Agents in a batch." })),
20
+ background: Type.Optional(Type.Boolean({ description: "Return a run ID immediately while the batch continues in this parent session." })),
21
+ }, { additionalProperties: false });
@@ -17,6 +17,9 @@ export interface AgentDefinition {
17
17
  description: string;
18
18
  model?: string;
19
19
  tools?: string[];
20
+ access?: "read" | "write";
21
+ thinking?: string;
22
+ skills?: string[];
20
23
  systemPrompt: string;
21
24
  filePath: string;
22
25
  }
@@ -70,24 +73,32 @@ export function parseAgentMarkdown(content: string, filePath = "<memory>"): Agen
70
73
  }
71
74
 
72
75
  const model = fields.get("model")?.trim() || undefined;
76
+ const rawAccess = fields.get("access")?.trim();
77
+ if (rawAccess && rawAccess !== "read" && rawAccess !== "write") throw new Error("frontmatter access must be read or write");
78
+ const access = rawAccess as "read" | "write" | undefined;
79
+ const thinking = fields.get("thinking")?.trim() || undefined;
80
+ const skills = fields.has("skills") ? parseList(fields.get("skills") ?? "") : undefined;
73
81
  return {
74
82
  name,
75
83
  description,
76
84
  ...(model ? { model } : {}),
77
85
  ...(tools ? { tools } : {}),
86
+ ...(access ? { access } : {}),
87
+ ...(thinking ? { thinking } : {}),
88
+ ...(skills ? { skills } : {}),
78
89
  systemPrompt: lines.slice(end + 1).join("\n").trim(),
79
90
  filePath,
80
91
  };
81
92
  }
82
93
 
83
- export function discoverPackageAgents(agentsDir: string): AgentDiscovery {
94
+ export function discoverPackageAgents(agentsDir: string, optional = false): AgentDiscovery {
84
95
  const agents: AgentDefinition[] = [];
85
96
  const diagnostics: string[] = [];
86
97
  let entries: string[];
87
98
  try {
88
99
  entries = readdirSync(agentsDir).filter((entry) => entry.endsWith(".md")).sort();
89
100
  } catch (error) {
90
- return { agents, diagnostics: [`Cannot read persona directory ${agentsDir}: ${error instanceof Error ? error.message : String(error)}`] };
101
+ return { agents, diagnostics: optional ? [] : [`Cannot read persona directory ${agentsDir}: ${error instanceof Error ? error.message : String(error)}`] };
91
102
  }
92
103
 
93
104
  const names = new Set<string>();
@@ -0,0 +1,23 @@
1
+ export type ReportPromptKind = "initial" | "resumed" | "followup";
2
+
3
+ const TURN_INTRO: Record<ReportPromptKind, string> = {
4
+ initial: "",
5
+ resumed: "This is a resumed turn.",
6
+ followup: "This is a new turn in the same logical task.",
7
+ };
8
+
9
+ export function reportProtocolPrompt(systemPrompt: string, kind: ReportPromptKind = "initial"): string {
10
+ return [
11
+ TURN_INTRO[kind],
12
+ systemPrompt.trim(),
13
+ "",
14
+ "When this turn is finished, call subagent_report exactly once for this turn.",
15
+ "Put the complete final answer in result, a concise parent-facing paragraph in summary, and list any useful document paths.",
16
+ "Use status=needs-input with question when missing information prevents progress, or status=failed with an error when the task cannot be completed.",
17
+ ].filter((line, index) => line || index > 1).join("\n").trim();
18
+ }
19
+
20
+ export function continuationSystemPrompt(originalPrompt: string | undefined, instruction: string, kind: Exclude<ReportPromptKind, "initial">): string {
21
+ if (!originalPrompt?.trim()) return reportProtocolPrompt(instruction, kind);
22
+ return [TURN_INTRO[kind], originalPrompt.trim(), "", instruction.trim()].join("\n").trim();
23
+ }