@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,175 @@
1
+ import { existsSync, readdirSync } from "node:fs";
2
+ import { mkdir, rename } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ import { CliHerdrAutomation, type HerdrAutomation } from "./herdr.ts";
5
+ import { continueQueuedRun } from "./continue-run.ts";
6
+ import { liveAgentName, promptLiveAgent } from "./live-agent.ts";
7
+ import { writeJsonAtomic, writeTextAtomic } from "./state.ts";
8
+ import { cleanupRunTab } from "./tab-cleanup.ts";
9
+ import type { OwnerIdentity } from "./ownership.ts";
10
+ import { ownerRecord } from "./ownership.ts";
11
+ import { aggregateTaskStatus, applyRunStatus } from "./run-status.ts";
12
+ import { applyReport, applyTaskStatus, isActiveStatus, persistFailureReport, readReport, readRunRecord, readTaskRecord, readTurnRecord, settleAttemptFailure, type PersistedDocument, type PersistedReport, type RunRecord, type TaskRecord, type TaskStatus, type TurnRecord } from "./records.ts";
13
+ import { isAbortError } from "./errors.ts";
14
+ import { continuationSystemPrompt } from "./protocol.ts";
15
+ import { RunPaneAllocator } from "./run-pane-allocator.ts";
16
+ import { findRunDirectory } from "./run-locator.ts";
17
+
18
+ export interface RespondToBlockedTaskOptions { herdr?: HerdrAutomation; taskNumber?: number; signal?: AbortSignal; owner?: OwnerIdentity; onCleanupError?: (message: string) => void; onWarning?: (message: string) => void; onStalled?: (message: string) => void; onUpdate?: (result: ResumeBlockedResult) => void; }
19
+ export interface ResumeBlockedResult {
20
+ ok: boolean;
21
+ runId: string;
22
+ label: string;
23
+ status: "blocked" | "completed" | "partial" | "failed" | "cancelled";
24
+ summary: string;
25
+ question?: string;
26
+ documents: PersistedDocument[];
27
+ recordDirectory: string;
28
+ requestedConcurrency: number;
29
+ effectiveConcurrency: number;
30
+ tasks?: Array<{ index: number; name: string; status: TaskStatus; summary: string; documents: PersistedDocument[]; error?: string; question?: string; paneId: string; recordDirectory: string; sessionFile: string }>;
31
+ activity?: Array<{ index: number; name: string; status: TaskStatus; paneId?: string }>;
32
+ tabId?: string;
33
+ }
34
+
35
+ async function emitProgress(options: RespondToBlockedTaskOptions, runDirectory: string, run: RunRecord, taskDirectories: string[], summary: string): Promise<void> {
36
+ if (!options.onUpdate) return;
37
+ const tasks = await Promise.all(taskDirectories.map((directory) => readTaskRecord(join(directory, "task.json"))));
38
+ options.onUpdate({
39
+ ok: false,
40
+ runId: run.id,
41
+ label: run.label,
42
+ status: "blocked",
43
+ summary,
44
+ documents: [],
45
+ recordDirectory: runDirectory,
46
+ requestedConcurrency: Number(run.requestedConcurrency ?? 1),
47
+ effectiveConcurrency: Number(run.effectiveConcurrency ?? 1),
48
+ activity: tasks.map((entry, index) => ({ index, name: String(entry.name ?? entry.agent ?? `Task ${index + 1}`), status: entry.status, ...(entry.paneId ? { paneId: entry.paneId } : {}) })),
49
+ ...(run.tabId ? { tabId: run.tabId } : {}),
50
+ });
51
+ }
52
+
53
+ async function projectSettledRun(runDirectory: string, runFile: string, run: RunRecord, taskDirectories: string[], herdr: HerdrAutomation, onCleanupError?: (message: string) => void): Promise<ResumeBlockedResult> {
54
+ const finalTasks = await Promise.all(taskDirectories.map((directory) => readTaskRecord(join(directory, "task.json"))));
55
+ const finalReports = await Promise.all(taskDirectories.map(async (directory, index) => {
56
+ const reportFile = join(directory, "report.json");
57
+ if (!existsSync(reportFile) && (finalTasks[index].status === "queued" || finalTasks[index].status === "cancelled")) return { status: "failed" as const, summary: finalTasks[index].error ?? (finalTasks[index].status === "queued" ? "Task is still queued." : "Task was cancelled."), documents: [] };
58
+ try { return await readReport(reportFile); }
59
+ catch (error) {
60
+ const detail = error instanceof Error ? error.message : String(error);
61
+ const failedReport = { status: "failed" as const, summary: `Could not read task report: ${detail}`, documents: [], error: detail };
62
+ applyReport(finalTasks[index], failedReport);
63
+ await Promise.all([writeJsonAtomic(join(directory, "task.json"), finalTasks[index]), persistFailureReport(reportFile, failedReport)]);
64
+ return failedReport;
65
+ }
66
+ }));
67
+ const aggregateStatus = aggregateTaskStatus(finalTasks.map((entry) => entry.status));
68
+ const runStatus = aggregateStatus === "running" ? "blocked" : aggregateStatus;
69
+ const blockedIndex = finalTasks.findIndex((entry) => entry.status === "blocked");
70
+ const blockedReport = blockedIndex >= 0 ? finalReports[blockedIndex] : undefined;
71
+ run = await readRunRecord(runFile);
72
+ applyRunStatus(run, runStatus, { question: blockedReport?.question });
73
+ await writeJsonAtomic(runFile, run);
74
+ if (runStatus !== "blocked" && run.tabId) await cleanupRunTab({ herdr, tabId: run.tabId, runFile, runRecord: run, onError: onCleanupError, failureStatus: "failed" });
75
+ const summary = runStatus === "completed" ? "All tasks completed after the answer." : runStatus === "blocked" ? `${finalTasks[blockedIndex]?.name ?? "Task"} needs input: ${blockedReport?.question ?? "Input required."}` : finalReports.map((entry, index) => `${finalTasks[index].name}: ${entry.summary}`).join("\n");
76
+ const activity = finalTasks.map((entry, index) => ({ index, name: String(entry.name ?? entry.agent ?? `Task ${index + 1}`), status: entry.status, ...(entry.paneId ? { paneId: String(entry.paneId) } : {}) }));
77
+ const tasks = finalTasks.map((entry, index) => ({ index, name: activity[index].name, status: entry.status, summary: String(finalReports[index].summary ?? "Task has no report."), documents: finalReports[index].documents ?? [], ...(entry.error ? { error: String(entry.error) } : {}), ...(entry.question ? { question: String(entry.question) } : {}), paneId: String(entry.paneId ?? ""), recordDirectory: taskDirectories[index], sessionFile: String(entry.sessionFile ?? "") }));
78
+ return { ok: runStatus === "completed", runId: run.id, label: String(run.label ?? "batch"), status: runStatus, summary, ...(blockedReport?.question ? { question: blockedReport.question } : {}), documents: finalReports.flatMap((entry) => entry.documents ?? []), recordDirectory: runDirectory, requestedConcurrency: Number(run.requestedConcurrency ?? run.effectiveConcurrency ?? 1), effectiveConcurrency: Number(run.effectiveConcurrency ?? 1), tasks, activity, ...(run.tabId ? { tabId: String(run.tabId) } : {}) };
79
+ }
80
+
81
+ export async function respondToBlockedTask(cwd: string, runId: string, answer: string, options: RespondToBlockedTaskOptions = {}): Promise<ResumeBlockedResult> {
82
+ const herdr = options.herdr ?? new CliHerdrAutomation();
83
+ const { taskNumber, signal, onCleanupError } = options;
84
+ const runsDirectory = join(cwd, ".pi", "herdr-subagents", "runs");
85
+ const runDirectory = await findRunDirectory(runsDirectory, runId);
86
+ if (!runDirectory) throw new Error(`Unknown Subagent run '${runId}'.`);
87
+ const runFile = join(runDirectory, "run.json");
88
+ const run = await readRunRecord(runFile);
89
+ const taskDirectories = readdirSync(join(runDirectory, "tasks")).sort().map((entry) => join(runDirectory, "tasks", entry));
90
+ let taskDirectory: string | undefined;
91
+ let task: TaskRecord | undefined;
92
+ for (const directory of taskDirectories) {
93
+ const candidate = await readTaskRecord(join(directory, "task.json"));
94
+ if (candidate.status === "blocked" && (!taskNumber || candidate.order === taskNumber)) { taskDirectory = directory; task = candidate; break; }
95
+ }
96
+ if (!taskDirectory || !task) throw new Error(`Run '${runId}' has no blocked task.`);
97
+ Object.assign(run, ownerRecord(options.owner));
98
+ const previousReport = await readReport(join(taskDirectory, "report.json"));
99
+ const turnsDirectory = join(taskDirectory, "turns");
100
+ await mkdir(turnsDirectory, { recursive: true, mode: 0o700 });
101
+ const resumePrompt = join(taskDirectory, "resume-system-prompt.md");
102
+ await writeTextAtomic(resumePrompt, `${continuationSystemPrompt(task.systemPrompt, "Continue from the supplied answer.", "resumed")}\n`);
103
+ let paneId = String(task.paneId ?? "");
104
+ let agentName = String(task.agentName ?? liveAgentName(task.id));
105
+ let live = Boolean(paneId) && await herdr.paneExists(paneId, signal) && await herdr.isTaskRunning(paneId, signal);
106
+ if (!live) {
107
+ const paneAllocator = new RunPaneAllocator({ herdr, run, runFile, signal, validateExistingPane: true, onCleanupError });
108
+ paneId = await paneAllocator.allocate(taskDirectory);
109
+ agentName = liveAgentName(task.id, Number(task.attempt ?? 1) + 1);
110
+ task.attempt = Number(task.attempt ?? 1) + 1;
111
+ await herdr.renamePane(paneId, `${String(task.order).padStart(2, "0")} · ${task.name ?? task.agent}`, signal);
112
+ }
113
+ const attempt = Number(task.attempt ?? 1);
114
+ const turnFile = live && task.currentTurnFile ? String(task.currentTurnFile) : join(turnsDirectory, `${String(attempt).padStart(2, "0")}.json`);
115
+ let turn: TurnRecord = { attempt, prompt: task.prompt, status: "blocked", summary: previousReport.summary, question: previousReport.question, documents: previousReport.documents ?? [], sessionFile: task.sessionFile, startedAt: task.startedAt };
116
+ if (existsSync(turnFile)) {
117
+ try { turn = await readTurnRecord(turnFile); }
118
+ catch (error) {
119
+ const archivedPath = `${turnFile}.invalid-${Date.now()}`;
120
+ await rename(turnFile, archivedPath);
121
+ options.onWarning?.(`Archived invalid durable turn ${turnFile} at ${archivedPath}: ${error instanceof Error ? error.message : String(error)}`);
122
+ }
123
+ }
124
+ turn.events = [...(Array.isArray(turn.events) ? turn.events : []), { type: "answer", text: answer, at: new Date().toISOString() }];
125
+ task.currentTurnFile = turnFile;
126
+ applyTaskStatus(task, "running");
127
+ Object.assign(task, { tabId: run.tabId, paneId, agentName });
128
+ await Promise.all([writeJsonAtomic(join(taskDirectory, "task.json"), task), writeJsonAtomic(runFile, run), writeJsonAtomic(turnFile, turn)]);
129
+ await emitProgress(options, runDirectory, run, taskDirectories, `${task.name ?? task.agent} is continuing after input.`);
130
+ let report: PersistedReport;
131
+ let status: "blocked" | "completed" | "failed" | "cancelled";
132
+ let attemptError: unknown;
133
+ try {
134
+ report = (await promptLiveAgent({ herdr, task: { ...task, id: task.id, sessionFile: task.sessionFile }, paneId, prompt: answer, systemPromptFile: resumePrompt, agentName, start: !live, signal, stalledWarningMs: run.stalledWarningSeconds ? Number(run.stalledWarningSeconds) * 1000 : undefined, onStalled: () => options.onStalled?.(`${task.name ?? task.agent} appears stalled; it is still running.`) })).report;
135
+ status = applyReport(task, report);
136
+ } catch (error) {
137
+ attemptError = error;
138
+ const settled = await settleAttemptFailure(task, join(taskDirectory, "report.json"), {
139
+ cancelled: isAbortError(error, signal),
140
+ error: error instanceof Error ? error.message : String(error),
141
+ name: `${task.name ?? task.agent} response`,
142
+ replaceReport: true,
143
+ });
144
+ report = settled.report;
145
+ status = settled.status;
146
+ }
147
+ if (attemptError && isAbortError(attemptError, signal)) {
148
+ const message = attemptError instanceof Error ? attemptError.message : String(attemptError);
149
+ applyTaskStatus(task, "cancelled", { error: message });
150
+ applyRunStatus(run, "cancelled", { error: message });
151
+ applyTaskStatus(turn, "cancelled", { error: message });
152
+ for (const siblingDirectory of taskDirectories) {
153
+ if (siblingDirectory === taskDirectory) continue;
154
+ const siblingFile = join(siblingDirectory, "task.json");
155
+ const sibling = await readTaskRecord(siblingFile);
156
+ if (!isActiveStatus(sibling.status)) continue;
157
+ applyTaskStatus(sibling, "cancelled", { error: "Parent Pi session closed." });
158
+ await writeJsonAtomic(siblingFile, sibling);
159
+ }
160
+ await Promise.all([writeJsonAtomic(join(taskDirectory, "task.json"), task), writeJsonAtomic(runFile, run), writeJsonAtomic(turnFile, turn)]);
161
+ if (run.tabId) await cleanupRunTab({ herdr, tabId: run.tabId, runFile, runRecord: run, signal, onError: onCleanupError, failureStatus: "failed" });
162
+ return { ok: false, runId, label: String(run.label ?? "batch"), status: "cancelled", summary: "Subagent response cancelled.", documents: [], recordDirectory: runDirectory, requestedConcurrency: Number(run.requestedConcurrency ?? 1), effectiveConcurrency: Number(run.effectiveConcurrency ?? 1), ...(run.tabId ? { tabId: String(run.tabId) } : {}) };
163
+ }
164
+ turn.events.push({ type: "report", status, summary: report.summary, question: report.question, error: report.error, at: new Date().toISOString() });
165
+ Object.assign(turn, { summary: report.summary, documents: report.documents ?? [] });
166
+ applyReport(turn, report);
167
+ await Promise.all([writeJsonAtomic(join(taskDirectory, "task.json"), task), writeJsonAtomic(turnFile, turn)]);
168
+ await emitProgress(options, runDirectory, run, taskDirectories, `${task.name ?? task.agent} ${status}.`);
169
+ if (status !== "blocked") {
170
+ applyRunStatus(run, "queued");
171
+ await writeJsonAtomic(runFile, run);
172
+ await continueQueuedRun(runDirectory, herdr, { cleanup: false, signal, onStalled: options.onStalled, onCleanupError, onUpdate: () => emitProgress(options, runDirectory, run, taskDirectories, "Queued tasks are continuing.") });
173
+ }
174
+ return projectSettledRun(runDirectory, runFile, run, taskDirectories, herdr, onCleanupError);
175
+ }
@@ -0,0 +1,129 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { dirname, join } from "node:path";
4
+ import { discoverPackageAgents, type AgentDefinition } from "./personas.ts";
5
+
6
+ export type PersonaAccess = "read" | "write";
7
+ export interface PersonaConfig { model?: string; thinking?: string; skills?: string[]; }
8
+ export interface SubagentConfig {
9
+ defaultConcurrency: number;
10
+ maxConcurrency: number;
11
+ stalledWarningSeconds: number;
12
+ defaultModel?: string;
13
+ defaultThinking?: string;
14
+ personas: Record<string, PersonaConfig>;
15
+ }
16
+ export interface EffectivePersona extends AgentDefinition {
17
+ source: "project" | "global" | "package";
18
+ access: PersonaAccess;
19
+ model?: string;
20
+ modelSource: string;
21
+ thinking?: string;
22
+ thinkingSource: string;
23
+ skills: string[];
24
+ skillsSource: string;
25
+ }
26
+ export interface SubagentConfiguration {
27
+ settings: SubagentConfig;
28
+ settingSources: Record<string, string>;
29
+ personas: EffectivePersona[];
30
+ diagnostics: string[];
31
+ }
32
+ interface ConfigFile {
33
+ defaultConcurrency?: number;
34
+ maxConcurrency?: number;
35
+ stalledWarningSeconds?: number;
36
+ defaultModel?: string;
37
+ defaultThinking?: string;
38
+ personas?: Record<string, PersonaConfig>;
39
+ }
40
+ export interface ConfigurationPaths {
41
+ globalConfig?: string;
42
+ projectConfig?: string;
43
+ globalAgents?: string;
44
+ projectAgents?: string;
45
+ }
46
+ const DEFAULTS: SubagentConfig = { defaultConcurrency: 1, maxConcurrency: 4, stalledWarningSeconds: 300, personas: {} };
47
+
48
+ function readConfig(path: string, source: string, diagnostics: string[]): ConfigFile {
49
+ if (!existsSync(path)) return {};
50
+ try {
51
+ const value = JSON.parse(readFileSync(path, "utf8")) as unknown;
52
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("expected a JSON object");
53
+ return value as ConfigFile;
54
+ } catch (error) {
55
+ diagnostics.push(`${source} configuration ${path}: ${error instanceof Error ? error.message : String(error)}`);
56
+ return {};
57
+ }
58
+ }
59
+
60
+ function mergeConfig(base: SubagentConfig, input: ConfigFile, source: string, sources: Record<string, string>, diagnostics: string[]): SubagentConfig {
61
+ const result: SubagentConfig = { ...base, personas: { ...base.personas } };
62
+ for (const key of ["defaultConcurrency", "maxConcurrency", "stalledWarningSeconds"] as const) {
63
+ const value = input[key];
64
+ if (value === undefined) continue;
65
+ if (!Number.isInteger(value) || Number(value) <= 0) { diagnostics.push(`${source} ${key} must be a positive integer`); continue; }
66
+ result[key] = value as number;
67
+ sources[key] = source;
68
+ }
69
+ for (const key of ["defaultModel", "defaultThinking"] as const) {
70
+ const value = input[key];
71
+ if (value === undefined) continue;
72
+ if (typeof value === "string" && value.trim()) { result[key] = value.trim(); sources[key] = source; }
73
+ else diagnostics.push(`${source} ${key} must be a non-empty string`);
74
+ }
75
+ if (input.personas !== undefined) {
76
+ if (!input.personas || typeof input.personas !== "object" || Array.isArray(input.personas)) diagnostics.push(`${source} personas must be an object`);
77
+ else for (const [name, raw] of Object.entries(input.personas)) {
78
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) { diagnostics.push(`${source} persona '${name}' must be an object`); continue; }
79
+ const valid: PersonaConfig = {};
80
+ for (const field of ["model", "thinking"] as const) {
81
+ const value = raw[field];
82
+ if (value === undefined) continue;
83
+ if (typeof value === "string" && value.trim()) valid[field] = value.trim();
84
+ else diagnostics.push(`${source} persona '${name}' ${field} must be a non-empty string`);
85
+ }
86
+ if (raw.skills !== undefined) {
87
+ if (Array.isArray(raw.skills) && raw.skills.every((value) => typeof value === "string" && value.trim())) valid.skills = raw.skills.map((value) => value.trim());
88
+ else diagnostics.push(`${source} persona '${name}' skills must be an array of non-empty strings`);
89
+ }
90
+ input.personas[name] = valid;
91
+ result.personas[name] = { ...(result.personas[name] ?? {}), ...valid };
92
+ for (const field of Object.keys(valid)) sources[`personas.${name}.${field}`] = source;
93
+ }
94
+ }
95
+ return result;
96
+ }
97
+
98
+ export function loadSubagentConfiguration(cwd: string, packageAgentsDirectory: string, parentModel?: string, paths: ConfigurationPaths = {}): SubagentConfiguration {
99
+ const diagnostics: string[] = [];
100
+ const globalConfigPath = paths.globalConfig ?? join(homedir(), ".pi", "agent", "herdr-subagents.json");
101
+ const projectConfigPath = paths.projectConfig ?? join(cwd, ".pi", "herdr-subagents.json");
102
+ const globalAgentsDirectory = paths.globalAgents ?? join(dirname(globalConfigPath), "herdr-subagents", "agents");
103
+ const projectAgentsDirectory = paths.projectAgents ?? join(cwd, ".pi", "herdr-subagents", "agents");
104
+ const settingSources: Record<string, string> = { defaultConcurrency: "built-in", maxConcurrency: "built-in", stalledWarningSeconds: "built-in" };
105
+ const globalInput = readConfig(globalConfigPath, "global", diagnostics);
106
+ const projectInput = readConfig(projectConfigPath, "project", diagnostics);
107
+ const globalSettings = mergeConfig(DEFAULTS, globalInput, "global", settingSources, diagnostics);
108
+ const settings = mergeConfig(globalSettings, projectInput, "project", settingSources, diagnostics);
109
+ if (settings.defaultConcurrency > settings.maxConcurrency) { diagnostics.push("defaultConcurrency exceeds maxConcurrency; maxConcurrency is used"); settings.defaultConcurrency = settings.maxConcurrency; }
110
+ const layers = [
111
+ { source: "package" as const, discovery: discoverPackageAgents(packageAgentsDirectory) },
112
+ { source: "global" as const, discovery: discoverPackageAgents(globalAgentsDirectory, true) },
113
+ { source: "project" as const, discovery: discoverPackageAgents(projectAgentsDirectory, true) },
114
+ ];
115
+ const definitions = new Map<string, { definition: AgentDefinition; source: "project" | "global" | "package" }>();
116
+ for (const layer of layers) { diagnostics.push(...layer.discovery.diagnostics); for (const definition of layer.discovery.agents) definitions.set(definition.name, { definition, source: layer.source }); }
117
+ const personas = [...definitions.values()].map(({ definition, source }): EffectivePersona => {
118
+ const globalPersona = globalInput.personas?.[definition.name] ?? {};
119
+ const projectPersona = projectInput.personas?.[definition.name] ?? {};
120
+ const model = projectPersona.model ?? globalPersona.model ?? definition.model ?? settings.defaultModel ?? parentModel;
121
+ const modelSource = projectPersona.model ? "project persona" : globalPersona.model ? "global persona" : definition.model ? `${source} persona` : settings.defaultModel ? settingSources.defaultModel : parentModel ? "parent session" : "unresolved";
122
+ const thinking = projectPersona.thinking ?? globalPersona.thinking ?? definition.thinking ?? settings.defaultThinking;
123
+ const thinkingSource = projectPersona.thinking ? "project persona" : globalPersona.thinking ? "global persona" : definition.thinking ? `${source} persona` : settings.defaultThinking ? settingSources.defaultThinking : "default";
124
+ const skills = projectPersona.skills ?? globalPersona.skills ?? definition.skills ?? [];
125
+ const skillsSource = projectPersona.skills ? "project persona" : globalPersona.skills ? "global persona" : definition.skills ? `${source} persona` : "none";
126
+ return { ...definition, source, access: definition.access ?? "write", model, modelSource, thinking, thinkingSource, skills, skillsSource };
127
+ }).sort((left, right) => left.name.localeCompare(right.name));
128
+ return { settings, settingSources, personas, diagnostics };
129
+ }
@@ -0,0 +1,87 @@
1
+ import { readdirSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import type { HerdrAutomation } from "./herdr.ts";
4
+ import { liveAgentName, promptLiveAgent } from "./live-agent.ts";
5
+ import { writeJsonAtomic } from "./state.ts";
6
+ import { cleanupRunTab } from "./tab-cleanup.ts";
7
+ import { RunPaneAllocator } from "./run-pane-allocator.ts";
8
+ import { aggregateTaskStatus, applyRunStatus } from "./run-status.ts";
9
+ import { applyReport, applyTaskStatus, readRunRecord, readTaskRecord, settleAttemptFailure } from "./records.ts";
10
+ import { isAbortError } from "./errors.ts";
11
+
12
+ export interface ContinueQueuedRunOptions {
13
+ cleanup?: boolean;
14
+ onStalled?: (message: string) => void;
15
+ onCleanupError?: (message: string) => void;
16
+ onUpdate?: () => void | Promise<void>;
17
+ signal?: AbortSignal;
18
+ }
19
+
20
+ export async function continueQueuedRun(runDirectory: string, herdr: HerdrAutomation, options: ContinueQueuedRunOptions = {}): Promise<void> {
21
+ const runFile = join(runDirectory, "run.json");
22
+ const run = await readRunRecord(runFile);
23
+ const taskDirectories = readdirSync(join(runDirectory, "tasks")).sort().map((entry) => join(runDirectory, "tasks", entry));
24
+ const queuedDirectories: string[] = [];
25
+ let blockedCount = 0;
26
+ for (const directory of taskDirectories) {
27
+ const status = (await readTaskRecord(join(directory, "task.json"))).status;
28
+ if (status === "queued") queuedDirectories.push(directory);
29
+ if (status === "blocked") blockedCount += 1;
30
+ }
31
+ if (!queuedDirectories.length) return;
32
+ const availableSlots = Math.max(0, (Number(run.effectiveConcurrency) || 1) - blockedCount);
33
+ if (availableSlots === 0) return;
34
+ const concurrency = Math.min(availableSlots, queuedDirectories.length);
35
+ const paneAllocator = new RunPaneAllocator({ herdr, run, runFile, signal: options.signal, validateExistingPane: true, onCleanupError: options.onCleanupError });
36
+ let nextIndex = 0;
37
+ const runOne = async (directory: string): Promise<string> => {
38
+ const taskFile = join(directory, "task.json");
39
+ const task = await readTaskRecord(taskFile);
40
+ let paneId = "";
41
+ const reportFile = join(directory, "report.json");
42
+ try {
43
+ paneId = await paneAllocator.allocate(directory);
44
+ const agentName = liveAgentName(task.id, Number(task.attempt ?? 0) + 1);
45
+ const paneLabel = `${String(task.order).padStart(2, "0")} · ${task.name ?? task.agent}`;
46
+ await herdr.renamePane(paneId, paneLabel, options.signal);
47
+ applyTaskStatus(task, "running");
48
+ Object.assign(task, { tabId: run.tabId, paneId, paneLabel, agentName, attempt: Number(task.attempt ?? 0) + 1, startedAt: new Date().toISOString() });
49
+ await writeJsonAtomic(taskFile, task);
50
+ await options.onUpdate?.();
51
+ const report = (await promptLiveAgent({ herdr, task: { ...task, id: task.id, sessionFile: task.sessionFile }, paneId, prompt: task.prompt, systemPromptFile: join(directory, "system-prompt.md"), agentName, signal: options.signal, stalledWarningMs: run.stalledWarningSeconds ? Number(run.stalledWarningSeconds) * 1000 : undefined, onStalled: () => options.onStalled?.(`${task.name ?? task.agent} appears stalled; it is still running.`) })).report;
52
+ applyReport(task, report);
53
+ } catch (error) {
54
+ await settleAttemptFailure(task, reportFile, {
55
+ cancelled: isAbortError(error, options.signal),
56
+ error: error instanceof Error ? error.message : String(error),
57
+ name: String(task.name ?? task.agent ?? "Task"),
58
+ });
59
+ }
60
+ await writeJsonAtomic(taskFile, task);
61
+ await options.onUpdate?.();
62
+ return task.status;
63
+ };
64
+ const worker = async () => {
65
+ while (true) {
66
+ const index = nextIndex++;
67
+ if (index >= queuedDirectories.length) return;
68
+ if (await runOne(queuedDirectories[index]) === "blocked") return;
69
+ }
70
+ };
71
+ await Promise.all(Array.from({ length: concurrency }, worker));
72
+ if (options.signal?.aborted) {
73
+ for (const directory of taskDirectories) {
74
+ const taskFile = join(directory, "task.json");
75
+ const task = await readTaskRecord(taskFile);
76
+ if (task.status !== "queued") continue;
77
+ applyTaskStatus(task, "cancelled", { error: "Parent Pi session closed." });
78
+ await writeJsonAtomic(taskFile, task);
79
+ }
80
+ }
81
+ const statuses = await Promise.all(taskDirectories.map(async (directory) => (await readTaskRecord(join(directory, "task.json"))).status));
82
+ applyRunStatus(run, options.signal?.aborted ? "cancelled" : aggregateTaskStatus(statuses));
83
+ await writeJsonAtomic(runFile, run);
84
+ if (options.cleanup !== false && run.status !== "blocked" && run.tabId) {
85
+ await cleanupRunTab({ herdr, tabId: run.tabId, runFile, runRecord: run, signal: options.signal, onError: options.onCleanupError, failureStatus: "failed" });
86
+ }
87
+ }
@@ -0,0 +1,73 @@
1
+ interface QueuedRun<T> {
2
+ id: string;
3
+ execute: () => Promise<T>;
4
+ resolve: (value: T) => void;
5
+ reject: (error: unknown) => void;
6
+ }
7
+
8
+ export interface DispatcherSnapshot {
9
+ activeRunId?: string;
10
+ queuedRunIds: string[];
11
+ }
12
+
13
+ export class RunDispatcher {
14
+ private activeRunId?: string;
15
+ private readonly queue: QueuedRun<unknown>[] = [];
16
+ private readonly onChange?: (snapshot: DispatcherSnapshot) => void;
17
+ private paused = false;
18
+ private retainedRunId?: string;
19
+
20
+ constructor(onChange?: (snapshot: DispatcherSnapshot) => void) { this.onChange = onChange; }
21
+
22
+ submit<T>(id: string, execute: () => Promise<T>): Promise<T> {
23
+ const promise = new Promise<T>((resolve, reject) => this.queue.push({ id, execute, resolve: resolve as (value: unknown) => void, reject }));
24
+ this.changed();
25
+ void this.drain();
26
+ return promise;
27
+ }
28
+
29
+ snapshot(): DispatcherSnapshot {
30
+ return { ...(this.activeRunId ? { activeRunId: this.activeRunId } : {}), queuedRunIds: this.queue.map((run) => run.id) };
31
+ }
32
+
33
+ pause(): void { this.paused = true; this.changed(); }
34
+ resume(): void { this.paused = false; this.changed(); void this.drain(); }
35
+ retain(id: string): void {
36
+ if (this.activeRunId !== id) throw new Error(`Cannot retain inactive run '${id}'.`);
37
+ this.retainedRunId = id;
38
+ this.changed();
39
+ }
40
+ release(id: string): boolean {
41
+ if (this.activeRunId !== id || this.retainedRunId !== id) return false;
42
+ this.retainedRunId = undefined;
43
+ this.activeRunId = undefined;
44
+ this.changed();
45
+ void this.drain();
46
+ return true;
47
+ }
48
+ cancelQueued(reason: Error): string[] {
49
+ const cancelled = this.queue.splice(0);
50
+ for (const run of cancelled) run.reject(reason);
51
+ this.changed();
52
+ return cancelled.map((run) => run.id);
53
+ }
54
+
55
+ private changed(): void { this.onChange?.(this.snapshot()); }
56
+
57
+ private async drain(): Promise<void> {
58
+ if (this.activeRunId || this.paused) return;
59
+ const run = this.queue.shift();
60
+ if (!run) { this.changed(); return; }
61
+ this.activeRunId = run.id;
62
+ this.changed();
63
+ try { run.resolve(await run.execute()); }
64
+ catch (error) { run.reject(error); }
65
+ finally {
66
+ if (this.retainedRunId !== run.id) {
67
+ this.activeRunId = undefined;
68
+ this.changed();
69
+ void this.drain();
70
+ }
71
+ }
72
+ }
73
+ }
@@ -0,0 +1,3 @@
1
+ export function isAbortError(error: unknown, signal?: AbortSignal): boolean {
2
+ return signal?.aborted === true || (error instanceof Error && (error.name === "AbortError" || /abort/i.test(error.message)));
3
+ }
@@ -0,0 +1,82 @@
1
+ import type { ExtensionUIContext } from "@earendil-works/pi-coding-agent";
2
+ import { Container, isKeyRelease, matchesKey, Text } from "@earendil-works/pi-tui";
3
+ import type { HerdrAutomation } from "./herdr.ts";
4
+ import { fleetEditorHasFocus, FleetSelection, fleetLines } from "./fleet.ts";
5
+ import { focusActiveTask } from "./monitor.ts";
6
+ import { isActiveStatus } from "./records.ts";
7
+ import type { HerdrSubagentsBatchDetails } from "./tool.ts";
8
+
9
+ export class FleetController {
10
+ private activeBatch?: HerdrSubagentsBatchDetails;
11
+ private readonly selection = new FleetSelection(matchesKey);
12
+ private unsubscribeInput?: () => void;
13
+ private ui?: ExtensionUIContext;
14
+ private tui?: { focusedComponent?: unknown };
15
+
16
+ constructor(
17
+ private readonly herdr: HerdrAutomation,
18
+ private readonly isEditor: (value: unknown) => boolean,
19
+ private readonly createWidget?: (lines: string[]) => Container,
20
+ ) {}
21
+
22
+ get active(): HerdrSubagentsBatchDetails | undefined { return this.activeBatch; }
23
+
24
+ bind(ui: ExtensionUIContext, tuiMode: boolean): void {
25
+ this.unsubscribeInput?.();
26
+ this.ui = ui;
27
+ this.unsubscribeInput = tuiMode ? ui.onTerminalInput((data) => this.handleInput(data)) : undefined;
28
+ }
29
+
30
+ sync(details: unknown): void {
31
+ const batch = details as Partial<HerdrSubagentsBatchDetails>;
32
+ if (!batch.activity || !batch.label || !batch.status) return;
33
+ this.activeBatch = isActiveStatus(batch.status) ? batch as HerdrSubagentsBatchDetails : undefined;
34
+ this.render();
35
+ }
36
+
37
+ setActive(batch: HerdrSubagentsBatchDetails | undefined): void {
38
+ this.activeBatch = batch;
39
+ this.render();
40
+ }
41
+
42
+ clearRun(runId?: string): void {
43
+ if (!runId || this.activeBatch?.runId === runId) this.activeBatch = undefined;
44
+ this.render();
45
+ }
46
+
47
+ resetSelection(): void { this.selection.reset(); this.render(); }
48
+ async focus(taskNumber: number): Promise<boolean> { return this.activeBatch ? focusActiveTask(this.activeBatch, taskNumber, this.herdr) : false; }
49
+
50
+ shutdown(): void {
51
+ this.unsubscribeInput?.();
52
+ this.unsubscribeInput = undefined;
53
+ this.activeBatch = undefined;
54
+ this.selection.reset();
55
+ this.render();
56
+ }
57
+
58
+ render(): void {
59
+ if (!this.ui) return;
60
+ this.ui.setWidget("herdr-subagents", this.activeBatch ? ((tui) => {
61
+ this.tui = tui as { focusedComponent?: unknown };
62
+ const lines = fleetLines(this.activeBatch as HerdrSubagentsBatchDetails, this.selection);
63
+ if (this.createWidget) return this.createWidget(lines);
64
+ const container = new Container();
65
+ for (const line of lines) container.addChild(new Text(line, 1, 0));
66
+ return container;
67
+ }) : undefined, { placement: "belowEditor" });
68
+ }
69
+
70
+ private handleInput(data: string): { consume: true } | undefined {
71
+ if (!this.activeBatch || isKeyRelease(data)) return undefined;
72
+ if (!fleetEditorHasFocus(this.tui?.focusedComponent, this.isEditor)) {
73
+ this.selection.reset();
74
+ this.render();
75
+ return undefined;
76
+ }
77
+ const result = this.selection.handle(data, this.ui?.getEditorText() ?? "", this.activeBatch.activity.length);
78
+ if (result.changed) this.render();
79
+ if (result.focusTask) void focusActiveTask(this.activeBatch, result.focusTask, this.herdr);
80
+ return result.consume ? { consume: true } : undefined;
81
+ }
82
+ }
@@ -0,0 +1,56 @@
1
+ import type { BatchResult } from "./batch-runner.ts";
2
+
3
+ type FleetKey = "up" | "down" | "enter" | "escape";
4
+ type KeyMatcher = (data: string, key: FleetKey) => boolean;
5
+
6
+ const legacyKeys: Record<FleetKey, string> = { up: "\u001b[A", down: "\u001b[B", enter: "\r", escape: "\u001b" };
7
+ const legacyMatchesKey: KeyMatcher = (data, key) => data === legacyKeys[key];
8
+
9
+ export interface FleetInputResult { consume?: boolean; focusTask?: number; changed?: boolean; }
10
+
11
+ export function fleetEditorHasFocus(focused: unknown, isEditor: (value: unknown) => boolean): boolean {
12
+ return isEditor(focused);
13
+ }
14
+
15
+ export class FleetSelection {
16
+ private selected = 0;
17
+ private selecting = false;
18
+ private readonly matchesKey: KeyMatcher;
19
+
20
+ constructor(matchesKey: KeyMatcher = legacyMatchesKey) { this.matchesKey = matchesKey; }
21
+
22
+ reset(): void { this.selected = 0; this.selecting = false; }
23
+ isSelecting(): boolean { return this.selecting; }
24
+ selectedIndex(taskCount: number): number { return Math.min(this.selected, Math.max(0, taskCount - 1)); }
25
+
26
+ handle(data: string, editorText: string, taskCount: number): FleetInputResult {
27
+ if (taskCount === 0) { this.reset(); return {}; }
28
+ if (!this.selecting) {
29
+ if (!this.matchesKey(data, "down") || editorText.length !== 0) return {};
30
+ this.selecting = true;
31
+ this.selected = 0;
32
+ return { consume: true, changed: true };
33
+ }
34
+ if (this.matchesKey(data, "up") || this.matchesKey(data, "down")) {
35
+ const delta = this.matchesKey(data, "up") ? -1 : 1;
36
+ this.selected = (this.selected + delta + taskCount) % taskCount;
37
+ return { consume: true, changed: true };
38
+ }
39
+ if (this.matchesKey(data, "enter")) return { consume: true, focusTask: this.selected + 1 };
40
+ if (this.matchesKey(data, "escape")) { this.selecting = false; return { consume: true, changed: true }; }
41
+ this.selecting = false;
42
+ return { changed: true };
43
+ }
44
+ }
45
+
46
+ export function fleetLines(batch: Pick<BatchResult, "label" | "activity">, selection: FleetSelection): string[] {
47
+ const counts = new Map<string, number>();
48
+ for (const task of batch.activity) counts.set(task.status, (counts.get(task.status) ?? 0) + 1);
49
+ const summary = ["running", "queued", "blocked", "failed", "completed", "cancelled"]
50
+ .filter((status) => counts.has(status)).map((status) => `${status} ${counts.get(status)}`).join(" · ");
51
+ const selected = selection.selectedIndex(batch.activity.length);
52
+ return [
53
+ `Subagents · ${batch.label} · ${summary}`,
54
+ ...batch.activity.map((task, index) => `${selection.isSelecting() && index === selected ? "›" : " "} ${String(task.index + 1).padStart(2, "0")} · ${task.name} ${task.status}`),
55
+ ];
56
+ }