@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.
- package/CHANGELOG.md +6 -0
- package/README.md +1 -1
- package/package.json +5 -5
- package/packages/herdr-subagents/CHANGELOG.md +41 -0
- package/packages/herdr-subagents/README.md +155 -0
- package/packages/herdr-subagents/TESTING.md +24 -0
- package/packages/herdr-subagents/agents/explorer.md +8 -0
- package/packages/herdr-subagents/agents/reviewer.md +8 -0
- package/packages/{tiny-subagent → herdr-subagents}/agents/worker.md +1 -0
- package/packages/herdr-subagents/docs/demo.gif +0 -0
- package/packages/herdr-subagents/docs/screenshot.png +0 -0
- package/packages/herdr-subagents/extensions/herdr-subagents.ts +258 -0
- package/packages/herdr-subagents/extensions/subagent-report.ts +65 -0
- package/packages/{tiny-subagent → herdr-subagents}/package.json +7 -6
- package/packages/herdr-subagents/src/batch-runner.ts +219 -0
- package/packages/herdr-subagents/src/blocking.ts +175 -0
- package/packages/herdr-subagents/src/config.ts +125 -0
- package/packages/herdr-subagents/src/continue-run.ts +87 -0
- package/packages/herdr-subagents/src/dispatcher.ts +73 -0
- package/packages/herdr-subagents/src/errors.ts +3 -0
- package/packages/herdr-subagents/src/fleet-controller.ts +82 -0
- package/packages/herdr-subagents/src/fleet.ts +56 -0
- package/packages/herdr-subagents/src/herdr.ts +224 -0
- package/packages/herdr-subagents/src/history.ts +210 -0
- package/packages/herdr-subagents/src/live-agent.ts +87 -0
- package/packages/herdr-subagents/src/monitor.ts +18 -0
- package/packages/herdr-subagents/src/ownership.ts +17 -0
- package/packages/herdr-subagents/src/parameters.ts +21 -0
- package/packages/{tiny-subagent → herdr-subagents}/src/personas.ts +13 -2
- package/packages/herdr-subagents/src/protocol.ts +23 -0
- package/packages/herdr-subagents/src/queued-runs.ts +70 -0
- package/packages/herdr-subagents/src/reconcile.ts +167 -0
- package/packages/herdr-subagents/src/records.ts +213 -0
- package/packages/herdr-subagents/src/result-path.ts +7 -0
- package/packages/herdr-subagents/src/run-locator.ts +19 -0
- package/packages/herdr-subagents/src/run-pane-allocator.ts +78 -0
- package/packages/herdr-subagents/src/run-status.ts +23 -0
- package/packages/herdr-subagents/src/runner.ts +250 -0
- package/packages/herdr-subagents/src/state.ts +11 -0
- package/packages/herdr-subagents/src/tab-cleanup.ts +68 -0
- package/packages/herdr-subagents/src/tool.ts +217 -0
- package/packages/herdr-subagents/src/validation.ts +38 -0
- package/packages/herdr-subagents/tests/extension-contract.ts +225 -0
- package/packages/herdr-subagents/tests/run-tests.mjs +889 -0
- package/packages/todo/extensions/todo.ts +1 -1
- package/packages/tiny-subagent/CHANGELOG.md +0 -12
- package/packages/tiny-subagent/README.md +0 -128
- package/packages/tiny-subagent/TESTING.md +0 -20
- package/packages/tiny-subagent/docs/demo.gif +0 -0
- package/packages/tiny-subagent/docs/screenshot.png +0 -0
- package/packages/tiny-subagent/extensions/tiny-subagent.ts +0 -228
- package/packages/tiny-subagent/src/runner.ts +0 -319
- package/packages/tiny-subagent/tests/run-tests.mjs +0 -111
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { existsSync, readdirSync } from "node:fs";
|
|
2
|
+
import { mkdir, readFile, rm } from "node:fs/promises";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import type { HerdrSubagentsToolParams } from "./tool.ts";
|
|
5
|
+
import { isProcessAlive, ownerRecord, type OwnerIdentity } from "./ownership.ts";
|
|
6
|
+
import { writeJsonAtomic } from "./state.ts";
|
|
7
|
+
import { findRunDirectory } from "./run-locator.ts";
|
|
8
|
+
|
|
9
|
+
export interface QueuedRunRecord { runId: string; cwd: string; ownerSessionId?: string; ownerProcessId?: number; params: HerdrSubagentsToolParams; queuedAt: string; }
|
|
10
|
+
function queueDirectory(cwd: string): string { return join(cwd, ".pi", "herdr-subagents", "queue"); }
|
|
11
|
+
|
|
12
|
+
export async function persistQueuedRun(cwd: string, runId: string, params: HerdrSubagentsToolParams, owner?: OwnerIdentity): Promise<void> {
|
|
13
|
+
const directory = queueDirectory(cwd);
|
|
14
|
+
await mkdir(directory, { recursive: true, mode: 0o700 });
|
|
15
|
+
await writeJsonAtomic(join(directory, `${runId}.json`), { runId, cwd, ...ownerRecord(owner), params: { ...params, background: true }, queuedAt: new Date().toISOString() });
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export async function removeQueuedRun(cwd: string, runId: string): Promise<void> {
|
|
19
|
+
await rm(join(queueDirectory(cwd), `${runId}.json`), { force: true });
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export async function loadQueuedRuns(cwd: string, onError?: (message: string) => void): Promise<QueuedRunRecord[]> {
|
|
23
|
+
const directory = queueDirectory(cwd);
|
|
24
|
+
if (!existsSync(directory)) return [];
|
|
25
|
+
const records: QueuedRunRecord[] = [];
|
|
26
|
+
for (const entry of readdirSync(directory).filter((name) => name.endsWith(".json")).sort()) {
|
|
27
|
+
const path = join(directory, entry);
|
|
28
|
+
try { records.push(JSON.parse(await readFile(path, "utf8")) as QueuedRunRecord); }
|
|
29
|
+
catch (error) {
|
|
30
|
+
const message = `Could not read queued Subagent run ${path}: ${error instanceof Error ? error.message : String(error)}`;
|
|
31
|
+
if (!onError) throw new Error(message);
|
|
32
|
+
onError(message);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return records.sort((left, right) => left.queuedAt.localeCompare(right.queuedAt));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface CancelQueuedRunsOptions { reason?: string; owner?: OwnerIdentity; onlyOrphaned?: boolean; onError?: (message: string) => void; }
|
|
39
|
+
|
|
40
|
+
export async function cancelQueuedRuns(cwd: string, runIds: string[], options: CancelQueuedRunsOptions = {}): Promise<void> {
|
|
41
|
+
const reason = options.reason ?? "Parent Pi session closed.";
|
|
42
|
+
const selected = new Set(runIds);
|
|
43
|
+
for (const queued of await loadQueuedRuns(cwd, options.onError)) {
|
|
44
|
+
if (!selected.has(queued.runId)) continue;
|
|
45
|
+
if (!options.onlyOrphaned && options.owner?.sessionId && queued.ownerSessionId !== options.owner.sessionId) continue;
|
|
46
|
+
if (options.onlyOrphaned) {
|
|
47
|
+
const differentOwner = options.owner?.sessionId !== undefined && queued.ownerSessionId !== options.owner.sessionId;
|
|
48
|
+
if (!differentOwner) continue;
|
|
49
|
+
const ownedByAnotherLiveProcess = queued.ownerProcessId !== (options.owner?.processId ?? process.pid) && isProcessAlive(queued.ownerProcessId);
|
|
50
|
+
if (ownedByAnotherLiveProcess) continue;
|
|
51
|
+
}
|
|
52
|
+
const runsDirectory = join(cwd, ".pi", "herdr-subagents", "runs");
|
|
53
|
+
const alreadyPersisted = Boolean(await findRunDirectory(runsDirectory, queued.runId));
|
|
54
|
+
if (alreadyPersisted) { await removeQueuedRun(cwd, queued.runId); continue; }
|
|
55
|
+
const now = new Date().toISOString();
|
|
56
|
+
const runDirectory = join(cwd, ".pi", "herdr-subagents", "runs", `${now.replace(/[-:.]/g, "").replace("Z", "Z-")}${queued.runId.slice(0, 8)}`);
|
|
57
|
+
const tasks = queued.params.tasks ?? [];
|
|
58
|
+
await mkdir(join(runDirectory, "tasks"), { recursive: true, mode: 0o700 });
|
|
59
|
+
const taskIds: string[] = [];
|
|
60
|
+
for (const [index, task] of tasks.entries()) {
|
|
61
|
+
const id = `${queued.runId}-queued-${index + 1}`;
|
|
62
|
+
taskIds.push(id);
|
|
63
|
+
const directory = join(runDirectory, "tasks", `${String(index + 1).padStart(2, "0")}-queued`);
|
|
64
|
+
await mkdir(directory, { recursive: true, mode: 0o700 });
|
|
65
|
+
await writeJsonAtomic(join(directory, "task.json"), { id, runId: queued.runId, order: index + 1, name: task.name, prompt: task.prompt, agent: task.agent ?? "worker", model: task.model, status: "cancelled", error: reason, sessionFile: join(directory, "session.jsonl"), startedAt: queued.queuedAt, completedAt: now });
|
|
66
|
+
}
|
|
67
|
+
await writeJsonAtomic(join(runDirectory, "run.json"), { id: queued.runId, label: queued.params.label ?? "batch", status: "cancelled", cwd, ownerSessionId: queued.ownerSessionId, ownerProcessId: queued.ownerProcessId, taskIds, startedAt: queued.queuedAt, completedAt: now, error: reason });
|
|
68
|
+
await removeQueuedRun(cwd, queued.runId);
|
|
69
|
+
}
|
|
70
|
+
}
|