@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,250 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { mkdir, writeFile } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ import { CliHerdrAutomation, type HerdrAutomation } from "./herdr.ts";
5
+ import { ensureRuntimeIgnored } from "./history.ts";
6
+ import { liveAgentName, promptLiveAgent } from "./live-agent.ts";
7
+ import { writeJsonAtomic } 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 { applyReport, applyTaskStatus, settleAttemptFailure, type PersistedDocument, type PersistedReport, type RunRecord, type TaskRecord } from "./records.ts";
12
+ import { reportProtocolPrompt } from "./protocol.ts";
13
+ import { applyRunStatus } from "./run-status.ts";
14
+ import { isAbortError } from "./errors.ts";
15
+
16
+ const CHILD_ENV = "PI_HERDR_SUBAGENTS_CHILD";
17
+ const TASK_DIRECTORY_ENV = "PI_HERDR_SUBAGENTS_TASK_DIR";
18
+
19
+ export type HerdrSubagentsDocument = PersistedDocument;
20
+ export type HerdrSubagentsReport = PersistedReport;
21
+
22
+ export interface HerdrSubagentsOptions {
23
+ agent?: string;
24
+ label?: string;
25
+ model?: string;
26
+ thinking?: string;
27
+ tools?: string[];
28
+ skills?: string[];
29
+ access?: "read" | "write";
30
+ systemPrompt?: string;
31
+ cwd?: string;
32
+ signal?: AbortSignal;
33
+ herdr?: HerdrAutomation;
34
+ owner?: OwnerIdentity;
35
+ onUpdate?: (result: HerdrSubagentsResult) => void;
36
+ onCleanupError?: (message: string) => void;
37
+ stalledWarningSeconds?: number;
38
+ onStalled?: (message: string) => void;
39
+ }
40
+
41
+ export interface HerdrSubagentsResult {
42
+ ok: boolean;
43
+ status: "starting" | "running" | "blocked" | "completed" | "failed" | "cancelled";
44
+ summary: string;
45
+ documents: HerdrSubagentsDocument[];
46
+ output: string;
47
+ errorMessage?: string;
48
+ runId?: string;
49
+ taskId?: string;
50
+ tabId?: string;
51
+ paneId?: string;
52
+ sessionFile?: string;
53
+ recordDirectory?: string;
54
+ model?: string;
55
+ cwd: string;
56
+ durationMs: number;
57
+ }
58
+
59
+ function compactText(report: HerdrSubagentsReport): string {
60
+ const lines = [report.summary];
61
+ if (report.documents.length > 0) {
62
+ lines.push("", "Documents:", ...report.documents.map((document) => `- ${document.description}: ${document.path}`));
63
+ }
64
+ if (report.error) lines.push("", `Error: ${report.error}`);
65
+ return lines.join("\n");
66
+ }
67
+
68
+ function failure(cwd: string, startedAt: number, errorMessage: string, fields: Partial<HerdrSubagentsResult> = {}): HerdrSubagentsResult {
69
+ return {
70
+ ok: false,
71
+ status: "failed",
72
+ summary: "Herdr Subagents failed.",
73
+ documents: [],
74
+ output: `Herdr Subagents failed.\nError: ${errorMessage}`,
75
+ errorMessage,
76
+ cwd,
77
+ durationMs: Date.now() - startedAt,
78
+ ...fields,
79
+ };
80
+ }
81
+
82
+ async function writeJson(path: string, value: unknown): Promise<void> {
83
+ await writeJsonAtomic(path, value);
84
+ }
85
+
86
+ export async function runHerdrSubagents(prompt: string, options: HerdrSubagentsOptions = {}): Promise<HerdrSubagentsResult> {
87
+ const startedAt = Date.now();
88
+ const cwd = options.cwd ?? process.cwd();
89
+ if (process.env.HERDR_ENV !== "1" || !process.env.HERDR_WORKSPACE_ID) {
90
+ return failure(cwd, startedAt, "Herdr Subagents must run from a Pi session inside Herdr.");
91
+ }
92
+
93
+ const runId = randomUUID();
94
+ const taskId = randomUUID();
95
+ const agent = options.agent?.trim() || "worker";
96
+ const label = options.label?.trim() || agent;
97
+ const runDirectory = join(cwd, ".pi", "herdr-subagents", "runs", `${new Date().toISOString().replace(/[-:.]/g, "").replace("Z", "Z-")}${runId.slice(0, 8)}`);
98
+ const taskDirectory = join(runDirectory, "tasks", `01-${taskId.slice(0, 8)}`);
99
+ const sessionFile = join(taskDirectory, "session.jsonl");
100
+ const systemPromptFile = join(taskDirectory, "system-prompt.md");
101
+ const reportFile = join(taskDirectory, "report.json");
102
+ const runFile = join(runDirectory, "run.json");
103
+ const taskFile = join(taskDirectory, "task.json");
104
+ const startedAtIso = new Date(startedAt).toISOString();
105
+ const systemPrompt = reportProtocolPrompt(options.systemPrompt ?? "You are a focused worker subagent.");
106
+ const runRecord: RunRecord = {
107
+ id: runId,
108
+ label,
109
+ status: "starting",
110
+ cwd,
111
+ ...ownerRecord(options.owner),
112
+ stalledWarningSeconds: options.stalledWarningSeconds,
113
+ taskIds: [taskId],
114
+ startedAt: startedAtIso,
115
+ };
116
+ const taskRecord: TaskRecord = {
117
+ id: taskId,
118
+ runId,
119
+ order: 1,
120
+ name: label,
121
+ status: "starting",
122
+ prompt,
123
+ agent,
124
+ ...(options.model ? { model: options.model } : {}),
125
+ ...(options.thinking ? { thinking: options.thinking } : {}),
126
+ ...(options.tools ? { tools: options.tools } : {}),
127
+ ...(options.skills ? { skills: options.skills } : {}),
128
+ ...(options.access ? { access: options.access } : {}),
129
+ systemPrompt,
130
+ sessionFile,
131
+ startedAt: startedAtIso,
132
+ };
133
+ await ensureRuntimeIgnored(cwd);
134
+ await mkdir(taskDirectory, { recursive: true, mode: 0o700 });
135
+ await writeFile(systemPromptFile, systemPrompt, { encoding: "utf8", mode: 0o600 });
136
+ await Promise.all([writeJson(runFile, runRecord), writeJson(taskFile, taskRecord)]);
137
+
138
+ const herdr = options.herdr ?? new CliHerdrAutomation();
139
+ let tabId: string | undefined;
140
+ let paneId: string | undefined;
141
+ let result: HerdrSubagentsResult | undefined;
142
+ const common = (): Partial<HerdrSubagentsResult> => ({
143
+ runId,
144
+ taskId,
145
+ ...(tabId ? { tabId } : {}),
146
+ ...(paneId ? { paneId } : {}),
147
+ sessionFile,
148
+ recordDirectory: taskDirectory,
149
+ ...(options.model ? { model: options.model } : {}),
150
+ });
151
+ const update = (status: HerdrSubagentsResult["status"], summary: string) => {
152
+ options.onUpdate?.({
153
+ ok: false,
154
+ status,
155
+ summary,
156
+ documents: [],
157
+ output: summary,
158
+ cwd,
159
+ durationMs: Date.now() - startedAt,
160
+ ...common(),
161
+ });
162
+ };
163
+ update("starting", "Creating Herdr tab...");
164
+
165
+ let attempt: { status: "blocked" | "completed" | "failed" | "cancelled"; report: PersistedReport; error?: unknown };
166
+ try {
167
+ const tab = await herdr.createTab({
168
+ workspaceId: process.env.HERDR_WORKSPACE_ID,
169
+ cwd,
170
+ label: `SA · ${label}`,
171
+ env: {
172
+ [CHILD_ENV]: "1",
173
+ [TASK_DIRECTORY_ENV]: taskDirectory,
174
+ },
175
+ focus: false,
176
+ signal: options.signal,
177
+ });
178
+ tabId = tab.tabId;
179
+ paneId = tab.paneId;
180
+ const paneLabel = `01 · ${label}`;
181
+ await herdr.renamePane(paneId, paneLabel, options.signal);
182
+ applyRunStatus(runRecord, "running");
183
+ Object.assign(runRecord, { tabId, paneId });
184
+ applyTaskStatus(taskRecord, "running");
185
+ Object.assign(taskRecord, { tabId, paneId, paneLabel });
186
+ await Promise.all([writeJson(runFile, runRecord), writeJson(taskFile, taskRecord)]);
187
+
188
+ const name = liveAgentName(taskId);
189
+ taskRecord.agentName = name;
190
+ await writeJson(taskFile, taskRecord);
191
+ update("starting", "Starting child Pi in Herdr...");
192
+ update("running", "Child Pi is working...");
193
+ const report = (await promptLiveAgent({ herdr, task: { ...taskRecord, id: taskId, sessionFile }, paneId, prompt, systemPromptFile, agentName: name, signal: options.signal, stalledWarningMs: options.stalledWarningSeconds ? options.stalledWarningSeconds * 1000 : undefined, onStalled: () => options.onStalled?.(`${label} appears stalled; it is still running.`) })).report;
194
+ attempt = { status: applyReport(taskRecord, report), report };
195
+ } catch (error) {
196
+ const settled = await settleAttemptFailure(taskRecord, reportFile, {
197
+ cancelled: isAbortError(error, options.signal),
198
+ error: error instanceof Error ? error.message : String(error),
199
+ name: "Herdr Subagents",
200
+ summary: isAbortError(error, options.signal) ? "Herdr Subagents was cancelled." : "Herdr Subagents failed.",
201
+ });
202
+ attempt = { ...settled, error };
203
+ }
204
+ try {
205
+ if (!attempt.error) {
206
+ const { report, status: settledStatus } = attempt;
207
+ const completedAt = new Date().toISOString();
208
+ applyRunStatus(runRecord, settledStatus, { at: completedAt, error: report.error, question: report.question });
209
+ await Promise.all([writeJson(runFile, runRecord), writeJson(taskFile, taskRecord)]);
210
+ result = {
211
+ ok: report.status === "completed",
212
+ status: settledStatus,
213
+ summary: report.summary,
214
+ documents: report.documents,
215
+ output: compactText(report),
216
+ ...(report.error || report.question ? { errorMessage: report.error ?? report.question } : {}),
217
+ cwd,
218
+ durationMs: Date.now() - startedAt,
219
+ ...common(),
220
+ };
221
+ } else {
222
+ const errorMessage = attempt.error instanceof Error ? attempt.error.message : String(attempt.error);
223
+ const cancelled = attempt.status === "cancelled";
224
+ const completedAt = new Date().toISOString();
225
+ applyRunStatus(runRecord, cancelled ? "cancelled" : "failed", { at: completedAt, error: errorMessage });
226
+ await Promise.all([writeJson(runFile, runRecord), writeJson(taskFile, taskRecord)]);
227
+ result = cancelled
228
+ ? { ...failure(cwd, startedAt, errorMessage, common()), status: "cancelled", summary: "Herdr Subagents cancelled.", output: `Herdr Subagents cancelled.\nError: ${errorMessage}` }
229
+ : failure(cwd, startedAt, errorMessage, common());
230
+ }
231
+ } finally {
232
+ if (tabId && result?.status !== "blocked") {
233
+ try {
234
+ await cleanupRunTab({ herdr, tabId, runFile, runRecord, signal: options.signal, onError: options.onCleanupError, failureStatus: "failed" });
235
+ } catch (error) {
236
+ const cleanupError = error instanceof Error ? error.message : String(error);
237
+ if (result?.ok) {
238
+ const errorMessage = `Task completed but Herdr tab cleanup failed: ${cleanupError}`;
239
+ const completedAt = new Date().toISOString();
240
+ applyRunStatus(runRecord, "failed", { at: completedAt, error: errorMessage });
241
+ applyTaskStatus(taskRecord, "failed", { at: completedAt, error: errorMessage });
242
+ await Promise.all([writeJson(runFile, runRecord), writeJson(taskFile, taskRecord)]);
243
+ result = failure(cwd, startedAt, errorMessage, common());
244
+ }
245
+ }
246
+ }
247
+ }
248
+
249
+ return result ?? failure(cwd, startedAt, "Herdr Subagents ended without a result.", common());
250
+ }
@@ -0,0 +1,11 @@
1
+ import { rename, writeFile } from "node:fs/promises";
2
+
3
+ export async function writeTextAtomic(path: string, content: string, mode = 0o600): Promise<void> {
4
+ const temporary = `${path}.tmp-${process.pid}-${Math.random().toString(16).slice(2)}`;
5
+ await writeFile(temporary, content, { encoding: "utf8", mode });
6
+ await rename(temporary, path);
7
+ }
8
+
9
+ export async function writeJsonAtomic(path: string, value: unknown): Promise<void> {
10
+ await writeTextAtomic(path, `${JSON.stringify(value, null, 2)}\n`);
11
+ }
@@ -0,0 +1,68 @@
1
+ import type { HerdrAutomation } from "./herdr.ts";
2
+ import { readRunRecord, type RunRecord } from "./records.ts";
3
+ import { writeJsonAtomic } from "./state.ts";
4
+
5
+ export async function cleanupRunTab(options: {
6
+ herdr: HerdrAutomation;
7
+ tabId: string;
8
+ runFile: string;
9
+ runRecord: RunRecord;
10
+ signal?: AbortSignal;
11
+ onError?: (message: string) => void;
12
+ onClosed?: (runRecord: RunRecord) => void | Promise<void>;
13
+ failureStatus?: "failed";
14
+ errorPrefix?: string;
15
+ }): Promise<{ deferred: boolean }> {
16
+ const mergeLatest = async (update: (run: RunRecord) => void | Promise<void>, requirePending = false, clearPending = true) => {
17
+ const latest = await readRunRecord(options.runFile);
18
+ if (requirePending && !latest.cleanupPendingTabIds?.includes(options.tabId)) return;
19
+ if (clearPending) {
20
+ latest.cleanupPendingTabIds = latest.cleanupPendingTabIds?.filter((tabId) => tabId !== options.tabId);
21
+ if (!latest.cleanupPendingTabIds?.length) delete latest.cleanupPendingTabIds;
22
+ }
23
+ await update(latest);
24
+ await writeJsonAtomic(options.runFile, latest);
25
+ const mutableRecord = options.runRecord as unknown as Record<string, unknown>;
26
+ for (const key of Object.keys(mutableRecord)) if (!(key in latest)) delete mutableRecord[key];
27
+ Object.assign(options.runRecord, latest);
28
+ };
29
+ const reportFailure = async (error: unknown, requirePending = false) => {
30
+ const detail = error instanceof Error ? error.message : String(error);
31
+ try {
32
+ await mergeLatest((latest) => Object.assign(latest, { cleanupPendingTabIds: [...new Set([...(latest.cleanupPendingTabIds ?? []), options.tabId])], cleanupError: detail, ...(options.failureStatus ? { status: options.failureStatus } : {}) }), requirePending, false);
33
+ } catch (persistError) {
34
+ options.onError?.(`${options.errorPrefix ?? "Herdr tab cleanup failed"}: ${detail}; could not persist cleanup state: ${persistError instanceof Error ? persistError.message : String(persistError)}`);
35
+ return;
36
+ }
37
+ options.onError?.(`${options.errorPrefix ?? "Herdr tab cleanup failed"}: ${detail}`);
38
+ };
39
+ const finishClosed = async (requirePending = false) => mergeLatest(async (latest) => options.onClosed?.(latest), requirePending);
40
+ try {
41
+ if (options.signal?.aborted) {
42
+ await options.herdr.closeTab(options.tabId);
43
+ await finishClosed();
44
+ return { deferred: false };
45
+ }
46
+ if (await options.herdr.isTabFocused(options.tabId, options.signal)) {
47
+ const latest = await readRunRecord(options.runFile);
48
+ latest.cleanupPendingTabIds = [...new Set([...(latest.cleanupPendingTabIds ?? []), options.tabId])];
49
+ await writeJsonAtomic(options.runFile, latest);
50
+ Object.assign(options.runRecord, latest);
51
+ void options.herdr.waitForTabUnfocused(options.tabId, options.signal)
52
+ .then(() => options.herdr.closeTab(options.tabId, options.signal))
53
+ .then(() => finishClosed(true))
54
+ .catch(async (error) => {
55
+ if (!options.signal?.aborted) { await reportFailure(error, true); return; }
56
+ try {
57
+ await options.herdr.closeTab(options.tabId);
58
+ await finishClosed(true);
59
+ } catch (closeError) { await reportFailure(closeError, true); }
60
+ });
61
+ return { deferred: true };
62
+ }
63
+ await options.herdr.closeTab(options.tabId, options.signal);
64
+ await finishClosed();
65
+ }
66
+ catch (error) { await reportFailure(error); throw error; }
67
+ return { deferred: false };
68
+ }
@@ -0,0 +1,217 @@
1
+ import type { HerdrAutomation } from "./herdr.ts";
2
+ import { loadSubagentConfiguration, type ConfigurationPaths, type SubagentConfiguration } from "./config.ts";
3
+ import { runHerdrSubagents, type HerdrSubagentsResult } from "./runner.ts";
4
+ import { runHerdrSubagentsBatch, type BatchResult, type BatchTask } from "./batch-runner.ts";
5
+ import { respondToBlockedTask } from "./blocking.ts";
6
+ import { cleanSubagentRun, historyText, listSubagentHistory, resumeHistoricalTask } from "./history.ts";
7
+ import { HERDR_ACTIONS, validateToolParams } from "./validation.ts";
8
+ import type { OwnerIdentity } from "./ownership.ts";
9
+
10
+ export interface HerdrSubagentsTaskParams { name: string; prompt: string; agent?: string; model?: string; }
11
+
12
+ export interface HerdrSubagentsToolParams {
13
+ action?: "list" | "respond" | "history" | "resume" | "cleanup";
14
+ prompt?: string;
15
+ agent?: string;
16
+ label?: string;
17
+ model?: string;
18
+ tasks?: HerdrSubagentsTaskParams[];
19
+ concurrency?: number;
20
+ background?: boolean;
21
+ runId?: string;
22
+ answer?: string;
23
+ task?: number;
24
+ }
25
+
26
+ export interface HerdrSubagentsToolContext {
27
+ cwd: string;
28
+ model?: { provider: string; id: string };
29
+ thinkingLevel?: string;
30
+ }
31
+
32
+ export interface HerdrSubagentsToolDependencies {
33
+ agentsDirectory: string;
34
+ herdr?: HerdrAutomation;
35
+ configurationPaths?: ConfigurationPaths;
36
+ owner?: OwnerIdentity;
37
+ runId?: string;
38
+ }
39
+
40
+ export type HerdrSubagentsDetails = HerdrSubagentsResult & {
41
+ agent: string;
42
+ description: string;
43
+ prompt: string;
44
+ personaPath: string;
45
+ };
46
+
47
+ export type HerdrSubagentsBatchDetails = BatchResult & { prompts: string[] };
48
+
49
+ type ToolResponse = {
50
+ content: Array<{ type: "text"; text: string }>;
51
+ details: unknown;
52
+ isError?: boolean;
53
+ };
54
+
55
+ function modelId(ctx: HerdrSubagentsToolContext): string | undefined {
56
+ return ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : undefined;
57
+ }
58
+
59
+ export function listText(discovery: SubagentConfiguration): string {
60
+ const lines = ["Available Herdr Subagents personas:"];
61
+ if (discovery.personas.length === 0) lines.push("- (none)");
62
+ for (const agent of discovery.personas) {
63
+ const model = agent.model ?? "unresolved";
64
+ const tools = agent.tools?.join(", ") ?? "Pi default built-in tools";
65
+ const skills = agent.skills.join(", ") || "none";
66
+ lines.push(`- ${agent.name}: ${agent.description} (source: ${agent.source}; access: ${agent.access}; model: ${model} [${agent.modelSource}]; thinking: ${agent.thinking ?? "default"}; tools: ${tools}; skills: ${skills})`);
67
+ }
68
+ if (discovery.diagnostics.length > 0) {
69
+ lines.push("", "Persona diagnostics:", ...discovery.diagnostics.map((diagnostic) => `- ${diagnostic}`));
70
+ }
71
+ return lines.join("\n");
72
+ }
73
+
74
+ function summaryText(result: HerdrSubagentsResult): string {
75
+ if (result.ok) return result.output;
76
+ const lines = [result.summary];
77
+ if (result.errorMessage) lines.push(`Error: ${result.errorMessage}`);
78
+ if (result.recordDirectory) lines.push(`Task record: ${result.recordDirectory}`);
79
+ return lines.join("\n");
80
+ }
81
+
82
+ interface ToolExecutionContext {
83
+ params: HerdrSubagentsToolParams;
84
+ signal?: AbortSignal;
85
+ onUpdate?: (response: ToolResponse) => void;
86
+ ctx: HerdrSubagentsToolContext;
87
+ dependencies: HerdrSubagentsToolDependencies;
88
+ discovery: SubagentConfiguration;
89
+ }
90
+
91
+ type ActionHandler = (execution: ToolExecutionContext) => Promise<ToolResponse>;
92
+
93
+ const errorResponse = (text: string, details: Record<string, unknown> = {}): ToolResponse => ({
94
+ content: [{ type: "text", text }], details: { ...details, errorMessage: text }, isError: true,
95
+ });
96
+
97
+ const actionHandlers = {
98
+ async history({ ctx }: ToolExecutionContext) {
99
+ const history = await listSubagentHistory(ctx.cwd);
100
+ return { content: [{ type: "text", text: historyText(history) }], details: { action: "history", runs: history } };
101
+ },
102
+ async cleanup({ params, ctx }: ToolExecutionContext) {
103
+ try {
104
+ const cleaned = await cleanSubagentRun(ctx.cwd, params.runId as string);
105
+ const text = cleaned ? `Removed Subagent run ${params.runId}.` : `Unknown Subagent run '${params.runId}'.`;
106
+ return { content: [{ type: "text", text }], details: { action: "cleanup", runId: params.runId, cleaned }, ...(cleaned ? {} : { isError: true }) };
107
+ } catch (error) {
108
+ const text = error instanceof Error ? error.message : String(error);
109
+ return errorResponse(text, { action: "cleanup", runId: params.runId, cleaned: false });
110
+ }
111
+ },
112
+ async resume({ params, signal, onUpdate, ctx, dependencies, discovery }: ToolExecutionContext) {
113
+ try {
114
+ const result = await resumeHistoricalTask(ctx.cwd, params.runId as string, params.task as number, (params.prompt as string).trim(), {
115
+ herdr: dependencies.herdr, signal, owner: dependencies.owner, stalledWarningSeconds: discovery.settings.stalledWarningSeconds,
116
+ onStalled: (text) => onUpdate?.({ content: [{ type: "text", text }], details: { action: "resume", runId: params.runId, stalled: true } }),
117
+ onCleanupError: (text) => onUpdate?.({ content: [{ type: "text", text }], details: { action: "resume", runId: params.runId, cleanupError: text } }),
118
+ });
119
+ return { content: [{ type: "text", text: result.summary }], details: result, ...(result.status === "failed" || result.status === "cancelled" ? { isError: true } : {}) };
120
+ } catch (error) { return errorResponse(error instanceof Error ? error.message : String(error)); }
121
+ },
122
+ async respond({ params, signal, onUpdate, ctx, dependencies }: ToolExecutionContext) {
123
+ try {
124
+ const result = await respondToBlockedTask(ctx.cwd, (params.runId as string).trim(), (params.answer as string).trim(), {
125
+ herdr: dependencies.herdr, taskNumber: params.task, signal, owner: dependencies.owner,
126
+ onUpdate: (partial) => onUpdate?.({ content: [{ type: "text", text: partial.summary }], details: partial }),
127
+ onStalled: (text) => onUpdate?.({ content: [{ type: "text", text }], details: { action: "respond", runId: params.runId, stalled: true } }),
128
+ onWarning: (text) => onUpdate?.({ content: [{ type: "text", text }], details: { action: "respond", runId: params.runId, warning: text } }),
129
+ onCleanupError: (text) => onUpdate?.({ content: [{ type: "text", text }], details: { action: "respond", runId: params.runId, cleanupError: text } }),
130
+ });
131
+ const text = [result.summary, ...(result.question ? [`Question: ${result.question}`] : []), ...result.documents.map((document) => `${document.description}: ${document.path}`)].join("\n");
132
+ return { content: [{ type: "text", text }], details: result, ...(result.status === "failed" || result.status === "partial" || result.status === "cancelled" ? { isError: true } : {}) };
133
+ } catch (error) { return errorResponse(error instanceof Error ? error.message : String(error)); }
134
+ },
135
+ async list({ discovery }: ToolExecutionContext) {
136
+ return { content: [{ type: "text", text: listText(discovery) }], details: { action: "list", agents: discovery.personas, diagnostics: discovery.diagnostics, settings: discovery.settings } };
137
+ },
138
+ } satisfies Record<keyof typeof HERDR_ACTIONS, ActionHandler>;
139
+
140
+ async function executeBatch({ params, signal, onUpdate, ctx, dependencies, discovery }: ToolExecutionContext): Promise<ToolResponse> {
141
+ const tasks = params.tasks as HerdrSubagentsTaskParams[];
142
+ const resolved: BatchTask[] = [];
143
+ for (const task of tasks) {
144
+ const personaName = task.agent?.trim() || "worker";
145
+ const persona = discovery.personas.find((candidate) => candidate.name === personaName);
146
+ if (!persona) return errorResponse(`Unknown persona '${personaName}'.\n\n${listText(discovery)}`);
147
+ resolved.push({ name: task.name, prompt: task.prompt, agent: persona.name, access: persona.access, model: task.model?.trim() || persona.model, thinking: persona.thinking ?? (persona.modelSource === "parent session" && !task.model ? ctx.thinkingLevel : undefined), tools: persona.tools, skills: persona.skills, systemPrompt: persona.systemPrompt });
148
+ }
149
+ try {
150
+ const result = await runHerdrSubagentsBatch(resolved, {
151
+ runId: dependencies.runId,
152
+ label: params.label?.trim() || "batch",
153
+ concurrency: params.concurrency ?? discovery.settings.defaultConcurrency,
154
+ maxConcurrency: discovery.settings.maxConcurrency,
155
+ stalledWarningSeconds: discovery.settings.stalledWarningSeconds,
156
+ cwd: ctx.cwd,
157
+ signal,
158
+ herdr: dependencies.herdr,
159
+ owner: dependencies.owner,
160
+ onCleanupError: (text) => onUpdate?.({ content: [{ type: "text", text }], details: { action: "batch", runId: dependencies.runId, cleanupError: text } }),
161
+ onUpdate: (partial) => onUpdate?.({ content: [{ type: "text", text: partial.summary }], details: { ...partial, prompts: tasks.map((task) => task.prompt) } }),
162
+ });
163
+ const lines = [result.summary, ...result.tasks.map((task) => `- ${task.name}: ${task.status} — ${task.summary}`)];
164
+ if (result.documents.length) lines.push("", "Documents:", ...result.documents.map((document) => `- ${document.description}: ${document.path}`));
165
+ return { content: [{ type: "text", text: lines.join("\n") }], details: { ...result, prompts: tasks.map((task) => task.prompt) }, ...(result.ok ? {} : { isError: true }) };
166
+ } catch (error) {
167
+ const text = error instanceof Error ? error.message : String(error);
168
+ const diagnostic = error as { recordDirectory?: string; runId?: string };
169
+ return errorResponse(text, { ...(diagnostic.recordDirectory ? { recordDirectory: diagnostic.recordDirectory } : {}), ...(diagnostic.runId ? { runId: diagnostic.runId } : {}) });
170
+ }
171
+ }
172
+
173
+ async function executeSingle({ params, signal, onUpdate, ctx, dependencies, discovery }: ToolExecutionContext): Promise<ToolResponse> {
174
+ const prompt = params.prompt as string;
175
+ const selectedName = params.agent?.trim() || "worker";
176
+ const persona = discovery.personas.find((agent) => agent.name === selectedName);
177
+ if (!persona) return errorResponse(`Unknown persona '${selectedName}'.\n\n${listText(discovery)}`);
178
+ const explicitModel = params.model?.trim();
179
+ const selectedModel = explicitModel || persona.model || modelId(ctx);
180
+ const selectedThinking = persona.thinking ?? (!explicitModel && persona.modelSource === "parent session" ? ctx.thinkingLevel : undefined);
181
+ const details = (result: HerdrSubagentsResult): HerdrSubagentsDetails => ({ ...result, agent: persona.name, description: persona.description, prompt, personaPath: persona.filePath });
182
+ const result = await runHerdrSubagents(prompt, {
183
+ agent: persona.name,
184
+ label: params.label,
185
+ ...(selectedModel ? { model: selectedModel } : {}),
186
+ ...(selectedThinking ? { thinking: selectedThinking } : {}),
187
+ ...(persona.tools ? { tools: persona.tools } : {}),
188
+ skills: persona.skills,
189
+ access: persona.access,
190
+ owner: dependencies.owner,
191
+ systemPrompt: persona.systemPrompt,
192
+ cwd: ctx.cwd,
193
+ signal,
194
+ herdr: dependencies.herdr,
195
+ stalledWarningSeconds: discovery.settings.stalledWarningSeconds,
196
+ onStalled: (text) => onUpdate?.({ content: [{ type: "text", text }], details: { status: "running", stalled: true } }),
197
+ onCleanupError: (text) => onUpdate?.({ content: [{ type: "text", text }], details: { status: "failed", cleanupError: text } }),
198
+ onUpdate: (partial) => onUpdate?.({ content: [{ type: "text", text: partial.summary }], details: details(partial) }),
199
+ });
200
+ return { content: [{ type: "text", text: summaryText(result) }], details: details(result), ...(result.ok ? {} : { isError: true }) };
201
+ }
202
+
203
+ export async function executeHerdrSubagents(
204
+ params: HerdrSubagentsToolParams,
205
+ signal: AbortSignal | undefined,
206
+ onUpdate: ((response: ToolResponse) => void) | undefined,
207
+ ctx: HerdrSubagentsToolContext,
208
+ dependencies: HerdrSubagentsToolDependencies,
209
+ ): Promise<ToolResponse> {
210
+ const validationError = validateToolParams(params);
211
+ if (validationError) return { content: [{ type: "text", text: validationError }], details: { errorMessage: validationError }, isError: true };
212
+ const discovery = loadSubagentConfiguration(ctx.cwd, dependencies.agentsDirectory, modelId(ctx), dependencies.configurationPaths);
213
+ const execution = { params, signal, onUpdate, ctx, dependencies, discovery };
214
+ if (params.action) return actionHandlers[params.action](execution);
215
+
216
+ return params.tasks ? executeBatch(execution) : executeSingle(execution);
217
+ }
@@ -0,0 +1,38 @@
1
+ import type { HerdrSubagentsToolParams } from "./tool.ts";
2
+
3
+ const executionFields = ["prompt", "agent", "label", "model", "tasks", "concurrency", "background"] as const;
4
+
5
+ export const HERDR_ACTIONS = {
6
+ list: { allowed: ["action"], includeRunId: false },
7
+ history: { allowed: ["action"], includeRunId: false },
8
+ cleanup: { allowed: ["action", "runId"], includeRunId: true },
9
+ respond: { allowed: ["action", "runId", "answer", "task"], includeRunId: true },
10
+ resume: { allowed: ["action", "runId", "task", "prompt"], includeRunId: true },
11
+ } as const satisfies Record<string, { allowed: readonly (keyof HerdrSubagentsToolParams)[]; includeRunId: boolean }>;
12
+
13
+ export function validateToolParams(params: HerdrSubagentsToolParams): string | undefined {
14
+ const present = (key: keyof HerdrSubagentsToolParams) => params[key] !== undefined;
15
+ const extraField = (allowed: readonly (keyof HerdrSubagentsToolParams)[]) => Object.keys(params).find((key) => !allowed.includes(key as keyof HerdrSubagentsToolParams));
16
+ if (params.action) {
17
+ const extra = extraField(HERDR_ACTIONS[params.action].allowed);
18
+ if (extra) return `Field '${extra}' is not valid with action '${params.action}'.`;
19
+ if (params.action === "cleanup" && !params.runId?.trim()) return "runId is required for action 'cleanup'.";
20
+ if (params.action === "respond" && (!params.runId?.trim() || !params.answer?.trim())) return "runId and answer are required for action 'respond'.";
21
+ if (params.action === "resume" && (!params.runId?.trim() || !params.task || !params.prompt?.trim())) return "runId, task, and prompt are required for action 'resume'.";
22
+ return undefined;
23
+ }
24
+ if (params.prompt && params.tasks) return "Provide either prompt or tasks, not both.";
25
+ if (params.prompt) {
26
+ const extra = extraField(["prompt", "agent", "label", "model"]);
27
+ if (extra) return `Field '${extra}' is not valid for single-task execution.`;
28
+ return undefined;
29
+ }
30
+ if (params.tasks) {
31
+ if (!params.tasks.length) return "tasks must contain at least one task.";
32
+ const extra = extraField(["tasks", "label", "concurrency", "background"]);
33
+ if (extra) return `Field '${extra}' is not valid for batch execution.`;
34
+ return undefined;
35
+ }
36
+ const supplied = executionFields.find((key) => present(key));
37
+ return supplied ? `Field '${supplied}' requires prompt or tasks.` : "Provide an action, prompt, or non-empty tasks list.";
38
+ }