@maxiaochao/pi-toolkit 0.11.6 → 0.11.7

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.
@@ -2,9 +2,9 @@ import { existsSync } from "node:fs";
2
2
  import { mkdir, readFile, rename } from "node:fs/promises";
3
3
  import { dirname, join } from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
- import type { HerdrAgentState, HerdrAutomation } from "./herdr.ts";
6
- import { applyTaskRuntime, parseReport, readTaskRecord, type PersistedReport, type TaskRecord, type TaskRuntimeState } from "./records.ts";
7
- import { attemptReportPath, attemptStatePath, createAttemptId, type AttemptState } from "./attempts.ts";
5
+ import type { HerdrAutomation } from "./herdr.ts";
6
+ import { parseReport, type PersistedReport, type TaskRecord } from "./records.ts";
7
+ import { attemptReportPath, attemptStatePath, createAttemptId } from "./attempts.ts";
8
8
  import { writeJsonAtomic } from "./state.ts";
9
9
 
10
10
  export interface LiveTaskRecord {
@@ -33,14 +33,11 @@ export function childEnvironment(taskDirectory: string, environment: NodeJS.Proc
33
33
 
34
34
  export function childPiArgs(task: LiveTaskRecord, systemPromptFile: string): string[] {
35
35
  const args = [
36
- "--approve",
37
- "--session", task.sessionFile,
36
+ "--approve", "--session", task.sessionFile,
38
37
  "--name", `Subagent: ${task.name ?? task.agent ?? "worker"}`,
39
- "--no-extensions",
40
- "--extension", "builtin:codemode",
38
+ "--no-extensions", "--extension", "builtin:codemode",
41
39
  "--extension", fileURLToPath(new URL("../extensions/subagent-report.ts", import.meta.url)),
42
- "--no-skills",
43
- "--append-system-prompt", systemPromptFile,
40
+ "--no-skills", "--append-system-prompt", systemPromptFile,
44
41
  ];
45
42
  for (const extension of task.extensions ?? []) args.push("--extension", extension);
46
43
  if (task.model) args.push("--model", task.model);
@@ -51,8 +48,17 @@ export function childPiArgs(task: LiveTaskRecord, systemPromptFile: string): str
51
48
  return args;
52
49
  }
53
50
 
54
- export function liveAgentName(taskId: string, attempt = 1): string {
55
- return `herdr-subagent-${taskId.replace(/[^a-zA-Z0-9]/g, "").slice(0, 12)}-${attempt}`;
51
+ export function liveAgentName(taskId: string, _attempt = 1): string {
52
+ return `herdr-subagent-${taskId.replace(/[^a-zA-Z0-9]/g, "").slice(0, 12)}`;
53
+ }
54
+
55
+ async function pause(signal?: AbortSignal): Promise<void> {
56
+ await new Promise<void>((resolve, reject) => {
57
+ const onAbort = () => { clearTimeout(timer); reject(signal?.reason ?? new Error("Subagent wait aborted.")); };
58
+ const timer = setTimeout(() => { signal?.removeEventListener("abort", onAbort); resolve(); }, 250);
59
+ if (signal?.aborted) onAbort();
60
+ else signal?.addEventListener("abort", onAbort, { once: true });
61
+ });
56
62
  }
57
63
 
58
64
  export async function promptLiveAgent(options: {
@@ -75,121 +81,52 @@ export async function promptLiveAgent(options: {
75
81
  const taskFile = join(taskDirectory, "task.json");
76
82
  const attemptId = options.attemptId ?? createAttemptId();
77
83
  const attemptReportFile = attemptReportPath(taskDirectory, attemptId);
78
- const attemptStateFile = attemptStatePath(taskDirectory, attemptId);
79
- await Promise.all([mkdir(dirname(attemptReportFile), { recursive: true, mode: 0o700 }), mkdir(dirname(attemptStateFile), { recursive: true, mode: 0o700 })]);
84
+ await Promise.all([
85
+ mkdir(dirname(attemptReportFile), { recursive: true, mode: 0o700 }),
86
+ mkdir(dirname(attemptStatePath(taskDirectory, attemptId)), { recursive: true, mode: 0o700 }),
87
+ ]);
80
88
  const durableTask = JSON.parse(await readFile(taskFile, "utf8")) as Record<string, unknown>;
81
89
  durableTask.currentAttemptId = attemptId;
82
90
  await writeJsonAtomic(taskFile, durableTask);
83
- const agentName = options.agentName ?? liveAgentName(options.task.id, options.attempt);
84
- if (options.start !== false) {
85
- await options.herdr.startAgent({
86
- name: agentName,
87
- kind: "pi",
88
- paneId: options.paneId,
89
- args: childPiArgs(options.task, options.systemPromptFile),
90
- signal: options.signal,
91
- });
92
- }
93
- const persistRuntime = async (state: TaskRuntimeState, diagnostic?: string) => {
94
- const task = await readTaskRecord(taskFile);
95
- if (task.currentAttemptId !== attemptId || (task.status !== "running" && task.status !== "starting")) return;
96
- if (task.runtimeState === state && task.runtimeDiagnostic === diagnostic) return;
97
- applyTaskRuntime(task, state, diagnostic);
98
- await writeJsonAtomic(taskFile, task);
99
- await options.onRuntimeUpdate?.(task);
100
- };
101
- const readAttemptState = async (): Promise<AttemptState | undefined> => {
102
- if (!existsSync(attemptStateFile)) return undefined;
103
- try {
104
- const value = JSON.parse(await readFile(attemptStateFile, "utf8")) as { attemptId?: unknown; state?: unknown };
105
- if (value.attemptId === attemptId && (value.state === "working" || value.state === "settled")) return value.state;
106
- } catch { /* a concurrent atomic replacement will be retried */ }
107
- return undefined;
108
- };
109
- const wait = () => new Promise<void>((resolve, reject) => {
110
- const onAbort = () => {
111
- clearTimeout(timer);
112
- if (existsSync(attemptReportFile)) resolve();
113
- else reject(options.signal?.reason ?? new Error("Subagent attempt aborted."));
114
- };
115
- const timer = setTimeout(() => { options.signal?.removeEventListener("abort", onAbort); resolve(); }, 100);
116
- if (options.signal?.aborted) onAbort();
117
- else options.signal?.addEventListener("abort", onAbort, { once: true });
91
+ const agentName = options.agentName ?? liveAgentName(options.task.id);
92
+ if (options.start !== false) await options.herdr.startAgent({
93
+ name: agentName, kind: "pi", paneId: options.paneId,
94
+ args: childPiArgs(options.task, options.systemPromptFile), signal: options.signal,
118
95
  });
96
+ // Delivery is attempted exactly once. Its acknowledgement is not a lifecycle
97
+ // signal: Pi can recover from a transport/provider error in the same process.
119
98
  const promptController = new AbortController();
120
- const abortPrompt = () => promptController.abort(options.signal?.reason ?? new Error("Subagent prompt wait stopped."));
121
- if (options.signal?.aborted) abortPrompt();
122
- else options.signal?.addEventListener("abort", abortPrompt, { once: true });
123
- const promptOutcome = options.herdr.promptAgent({ target: agentName, prompt: options.prompt, signal: promptController.signal })
124
- .then((result) => ({ result }), (error: unknown) => ({ error }));
125
- let promptSettled = false;
126
- let promptStatus: HerdrAgentState | undefined;
127
- let promptError: unknown;
128
- const promptSettlementWork = promptOutcome.then(async (outcome) => {
129
- promptSettled = true;
130
- if ("error" in outcome) {
131
- promptError = outcome.error;
132
- const detail = outcome.error instanceof Error ? outcome.error.message : String(outcome.error);
133
- if (/agent_prompt_stalled|timed?\s*out|timeout/i.test(detail)) await persistRuntime("delivery_unknown", detail);
134
- else if (/agent_blocked/i.test(detail)) await persistRuntime("waiting_interaction", "Herdr is waiting for an approval or interaction; no business input request has been reported.");
135
- } else if (outcome.result.status === "working" || outcome.result.status === "blocked" || outcome.result.status === "idle" || outcome.result.status === "done") promptStatus = outcome.result.status;
136
- });
99
+ const promptSignal = options.signal ? AbortSignal.any([options.signal, promptController.signal]) : promptController.signal;
100
+ const prompt = options.herdr.promptAgent({ target: agentName, prompt: options.prompt, signal: promptSignal }).catch(() => undefined);
137
101
  const stalledTimer = options.stalledWarningMs ? setTimeout(() => options.onStalled?.(), options.stalledWarningMs) : undefined;
138
- let terminalObservedAt: number | undefined;
139
102
  try {
140
- await persistRuntime("working");
141
- while (!existsSync(attemptReportFile)) {
142
- await Promise.resolve();
143
- let agentState = promptStatus;
144
- let observationDiagnostic: string | undefined;
145
- try { agentState = await options.herdr.getAgentState(agentName, options.signal); }
146
- catch (error) { observationDiagnostic = `Could not observe Herdr agent state: ${error instanceof Error ? error.message : String(error)}`; }
147
- const attemptState = await readAttemptState();
148
- const promptDetail = promptError instanceof Error ? promptError.message : promptError === undefined ? "" : String(promptError);
149
- if (agentState === "blocked" || /agent_blocked/i.test(promptDetail)) {
150
- await persistRuntime("waiting_interaction", observationDiagnostic ?? "Herdr is waiting for an approval or interaction; no business input request has been reported.");
151
- terminalObservedAt = undefined;
152
- } else if (promptError && /agent_prompt_stalled|timed?\s*out|timeout/i.test(promptDetail)) {
153
- await persistRuntime("delivery_unknown", observationDiagnostic ?? promptDetail);
154
- let live = false;
155
- try { live = await options.herdr.isTaskRunning(options.paneId, options.signal); } catch { live = true; }
156
- if (attemptState !== "working" || !live) {
157
- terminalObservedAt ??= Date.now();
158
- if (Date.now() - terminalObservedAt >= 50) throw promptError;
159
- }
160
- } else if (promptError && promptSettled) {
161
- terminalObservedAt ??= Date.now();
162
- if (Date.now() - terminalObservedAt >= 50) throw promptError;
163
- } else if ((promptSettled || attemptState === "settled") && (agentState === "idle" || agentState === "done" || attemptState === "settled")) {
164
- if (attemptState === "working") {
165
- let live = false;
166
- try { live = await options.herdr.isTaskRunning(options.paneId, options.signal); } catch { live = true; }
167
- if (!live) throw new Error("Child Pi stopped while its authoritative attempt state was still working and did not submit a subagent_report.");
168
- }
169
- terminalObservedAt ??= Date.now();
170
- if (Date.now() - terminalObservedAt >= 50) throw new Error("Child Pi did not submit a subagent_report before settling.");
171
- } else {
172
- await persistRuntime("working", observationDiagnostic);
173
- terminalObservedAt = undefined;
174
- if (!observationDiagnostic && agentState === "unknown") {
175
- let live = false;
176
- try { live = await options.herdr.isTaskRunning(options.paneId, options.signal); } catch { live = true; }
177
- if (!live) throw new Error("Child Pi stopped without submitting a subagent_report.");
178
- }
103
+ while (true) {
104
+ if (existsSync(attemptReportFile)) break;
105
+ if (options.signal?.aborted) {
106
+ if (existsSync(attemptReportFile)) break;
107
+ throw options.signal.reason ?? new Error("Subagent attempt aborted.");
179
108
  }
180
- await wait();
109
+ let alive = true;
110
+ try { alive = await options.herdr.isTaskRunning(options.paneId, options.signal); }
111
+ catch { /* an observation failure is not a child failure */ }
112
+ if (!alive) {
113
+ // The child publishes by atomic rename before it exits. Reconcile disk once;
114
+ // there is no timing-based drain and no semantic use of idle/done.
115
+ if (existsSync(attemptReportFile)) break;
116
+ throw new Error("Child Pi exited without submitting a subagent_report.");
117
+ }
118
+ if (options.herdr.waitForAgentChange) await options.herdr.waitForAgentChange(agentName, options.signal).catch(() => undefined);
119
+ else await pause(options.signal);
181
120
  }
182
121
  } finally {
183
122
  if (stalledTimer) clearTimeout(stalledTimer);
184
- promptController.abort(new Error("Authoritative subagent_report observed; stop waiting for prompt acknowledgement."));
185
- await promptSettlementWork;
186
- options.signal?.removeEventListener("abort", abortPrompt);
123
+ promptController.abort(new Error("Task lifecycle settled; stop waiting for prompt acknowledgement."));
187
124
  }
125
+ await prompt;
188
126
  let value: unknown;
189
127
  try { value = JSON.parse(await readFile(attemptReportFile, "utf8")); }
190
128
  catch {
191
129
  await rename(attemptReportFile, `${reportFile}.invalid-${Date.now()}`).catch(() => undefined);
192
- if (promptError) throw new Error(`${promptError instanceof Error ? promptError.message : String(promptError)}; Child Pi also submitted an invalid subagent_report.`);
193
130
  throw new Error("Child Pi submitted an invalid subagent_report.");
194
131
  }
195
132
  try {
@@ -197,10 +134,8 @@ export async function promptLiveAgent(options: {
197
134
  if (report.attemptId !== attemptId) throw new Error(`Report belongs to attempt '${report.attemptId ?? "unknown"}', expected '${attemptId}'.`);
198
135
  await writeJsonAtomic(reportFile, report);
199
136
  return { report, agentName, attemptId };
200
- }
201
- catch (error) {
137
+ } catch (error) {
202
138
  await rename(attemptReportFile, `${reportFile}.invalid-${Date.now()}`).catch(() => undefined);
203
- const detail = `Child Pi submitted an invalid subagent_report: ${error instanceof Error ? error.message : String(error)}`;
204
- throw new Error(promptError ? `${promptError instanceof Error ? promptError.message : String(promptError)}; ${detail}` : detail);
139
+ throw new Error(`Child Pi submitted an invalid subagent_report: ${error instanceof Error ? error.message : String(error)}`);
205
140
  }
206
141
  }
@@ -12,13 +12,9 @@ export const HerdrSubagentsParams = Type.Object({
12
12
  tasks: Type.Array(HerdrSubagentsTaskParams, { minItems: 1, description: "Ordered tasks to execute. Use one item for a single task; independent read-only tasks may run concurrently." }),
13
13
  label: Type.Optional(Type.String({ minLength: 1, maxLength: 48, description: "Short Herdr run-tab label." })),
14
14
  concurrency: Type.Optional(Type.Integer({ minimum: 1, description: "Maximum simultaneously active Agents. Write-capable tasks force concurrency one." })),
15
- background: Type.Optional(Type.Boolean({ description: "Return a run ID immediately while the run continues in this parent session." })),
16
15
  }, { additionalProperties: false });
17
16
 
18
17
  export const HerdrSubagentsControlParams = Type.Object({
19
- action: StringEnum(["list", "status", "respond", "history", "resume", "cancel", "cleanup"] as const, { description: "Control operation to perform." }),
20
- runId: Type.Optional(Type.String({ minLength: 1, description: "Durable run ID used by status, respond, resume, cancel, or cleanup." })),
21
- answer: Type.Optional(Type.String({ minLength: 1, description: "Message sent to the selected blocked or failed task when action is respond." })),
22
- task: Type.Optional(Type.Integer({ minimum: 1, description: "One-based task number. Always required for respond, and selects the task for resume." })),
23
- prompt: Type.Optional(Type.String({ minLength: 1, description: "Follow-up prompt used by resume." })),
18
+ action: StringEnum(["list", "status", "history", "cancel", "cleanup"] as const, { description: "Control operation to perform." }),
19
+ runId: Type.Optional(Type.String({ minLength: 1, description: "Durable run ID used by status, cancel, or cleanup." })),
24
20
  }, { additionalProperties: false });
@@ -1,4 +1,4 @@
1
- export type ReportPromptKind = "initial" | "resumed" | "followup";
1
+ export type ReportPromptKind = "initial" | "resumed" | "followup"; // legacy kinds remain readable
2
2
 
3
3
  const TURN_INTRO: Record<ReportPromptKind, string> = {
4
4
  initial: "",
@@ -11,11 +11,10 @@ export function reportProtocolPrompt(systemPrompt: string, kind: ReportPromptKin
11
11
  TURN_INTRO[kind],
12
12
  systemPrompt.trim(),
13
13
  "",
14
- "When this turn is finished, call subagent_report exactly once for this turn.",
14
+ "Before this process exits, call subagent_report exactly once with a terminal result.",
15
15
  "Put the complete final answer in result, a concise parent-facing paragraph in summary, and list any useful document paths.",
16
- "Use status=completed when the task is finished.",
17
- "Use status=blocked with question when you have a specific question or decision the parent agent must answer before you can continue; the run stays open waiting for it.",
18
- "Use status=failed with error when this task cannot be completed, so the run does not wait for an answer.",
16
+ "Use status=completed when the task is finished, or status=failed with error when it cannot be completed.",
17
+ "Do not pause for parent input; make a reasonable assumption or report failed with the missing requirement.",
19
18
  ].filter((line, index) => line || index > 1).join("\n").trim();
20
19
  }
21
20
 
@@ -252,6 +252,8 @@ export function applyLifecycleStatus<T extends { status: string; question?: stri
252
252
  }
253
253
 
254
254
  export function applyTaskStatus<T extends TaskRecord | TurnRecord>(record: T, status: TaskStatus, options: { question?: string; error?: string; at?: string } = {}): T {
255
+ const terminal = record.status === "completed" || record.status === "failed" || record.status === "cancelled";
256
+ if (terminal && record.status !== status) throw new Error(`Task is already terminal (${record.status}); terminal tasks are immutable.`);
255
257
  const at = options.at ?? new Date().toISOString();
256
258
  if ("order" in record) {
257
259
  if (record.status === "running" && status !== "running") {
@@ -1,12 +1,10 @@
1
1
  import { CliHerdrAutomation, type HerdrAutomation } from "./herdr.ts";
2
2
  import { loadSubagentConfiguration, type ConfigurationPaths, type SubagentConfiguration } from "./config.ts";
3
3
  import { runHerdrSubagentsBatch, type BatchResult, type BatchTask } from "./batch-runner.ts";
4
- import { respondToBlockedTask } from "./blocking.ts";
5
- import { cleanSubagentRun, historyText, listSubagentHistory, resumeHistoricalTask } from "./history.ts";
4
+ import { cleanSubagentRun, historyText, listSubagentHistory } from "./history.ts";
6
5
  import { HERDR_ACTIONS, validateControlParams, validateRunParams } from "./validation.ts";
7
6
  import type { OwnerIdentity } from "./ownership.ts";
8
7
  import { cancelActiveSubagentRuns } from "./reconcile.ts";
9
- import { cancelQueuedRuns, loadQueuedRuns } from "./queued-runs.ts";
10
8
  import { getSubagentRunStatus, runStatusText } from "./status.ts";
11
9
 
12
10
  export interface HerdrSubagentsTaskParams { name: string; prompt: string; agent?: string; model?: string; }
@@ -15,28 +13,20 @@ export interface HerdrSubagentsRunParams {
15
13
  tasks: HerdrSubagentsTaskParams[];
16
14
  label?: string;
17
15
  concurrency?: number;
18
- background?: boolean;
19
16
  }
20
17
 
21
18
  export interface HerdrSubagentsControlToolParams {
22
- action: "list" | "status" | "respond" | "history" | "resume" | "cancel" | "cleanup";
19
+ action: "list" | "status" | "history" | "cancel" | "cleanup";
23
20
  runId?: string;
24
- answer?: string;
25
- task?: number;
26
- prompt?: string;
27
21
  }
28
22
 
29
23
  /** Internal superset used by shared execution helpers. */
30
24
  export interface HerdrSubagentsToolParams {
31
- action?: "list" | "status" | "respond" | "history" | "resume" | "cancel" | "cleanup";
32
- prompt?: string;
25
+ action?: "list" | "status" | "history" | "cancel" | "cleanup";
33
26
  label?: string;
34
27
  tasks?: HerdrSubagentsTaskParams[];
35
28
  concurrency?: number;
36
- background?: boolean;
37
29
  runId?: string;
38
- answer?: string;
39
- task?: number;
40
30
  }
41
31
 
42
32
  export interface HerdrSubagentsToolContext {
@@ -51,7 +41,7 @@ export interface HerdrSubagentsToolDependencies {
51
41
  configurationPaths?: ConfigurationPaths;
52
42
  owner?: OwnerIdentity;
53
43
  runId?: string;
54
- cancelRun?: (runId: string) => Promise<{ cancelled: boolean; queued?: boolean; cleanupErrors?: string[] }>;
44
+ cancelRun?: (runId: string) => Promise<{ cancelled: boolean; cleanupErrors?: string[] }>;
55
45
  }
56
46
 
57
47
  export type HerdrSubagentsBatchDetails = BatchResult & { prompts: string[] };
@@ -127,51 +117,20 @@ const actionHandlers = {
127
117
  const requestedRunId = (params.runId as string).trim();
128
118
  try {
129
119
  const runId = (await getSubagentRunStatus(ctx.cwd, requestedRunId))?.runId ?? requestedRunId;
130
- let result: { cancelled: boolean; queued?: boolean; cleanupErrors?: string[] };
120
+ let result: { cancelled: boolean; cleanupErrors?: string[] };
131
121
  if (dependencies.cancelRun) result = await dependencies.cancelRun(runId);
132
122
  else {
133
123
  const reason = "Subagent run cancelled by user.";
134
- const queued = (await loadQueuedRuns(ctx.cwd)).some((run) => run.runId === runId);
135
- if (queued) {
136
- await cancelQueuedRuns(ctx.cwd, [runId], { reason, owner: dependencies.owner });
137
- result = { cancelled: true, queued: true };
138
- } else {
139
- const cancellation = await cancelActiveSubagentRuns(ctx.cwd, dependencies.herdr ?? new CliHerdrAutomation(), { reason, owner: dependencies.owner, runId });
140
- result = { cancelled: cancellation.cancelledRuns > 0, cleanupErrors: cancellation.cleanupErrors };
141
- }
124
+ const cancellation = await cancelActiveSubagentRuns(ctx.cwd, dependencies.herdr ?? new CliHerdrAutomation(), { reason, owner: dependencies.owner, runId });
125
+ result = { cancelled: cancellation.cancelledRuns > 0, cleanupErrors: cancellation.cleanupErrors };
142
126
  }
143
127
  const suffix = result.cleanupErrors?.length ? ` Cleanup warnings: ${result.cleanupErrors.join("; ")}` : "";
144
- const text = result.cancelled ? `Cancelled Subagent run ${runId}.${suffix}` : `Subagent run '${runId}' is not active or queued.`;
128
+ const text = result.cancelled ? `Cancelled Subagent run ${runId}.${suffix}` : `Subagent run '${runId}' is not active.`;
145
129
  return { content: [{ type: "text", text }], details: { action: "cancel", runId, ...result }, ...(result.cancelled ? {} : { isError: true }) };
146
130
  } catch (error) {
147
131
  return errorResponse(error instanceof Error ? error.message : String(error), { action: "cancel", runId: requestedRunId, cancelled: false });
148
132
  }
149
133
  },
150
- async resume({ params, signal, onUpdate, ctx, dependencies, discovery }: ToolExecutionContext) {
151
- try {
152
- const result = await resumeHistoricalTask(ctx.cwd, params.runId as string, params.task as number, (params.prompt as string).trim(), {
153
- herdr: dependencies.herdr, signal, owner: dependencies.owner, stalledWarningSeconds: discovery.settings.stalledWarningSeconds,
154
- onUpdate: (partial) => onUpdate?.({ content: [{ type: "text", text: partial.summary }], details: partial }),
155
- onStalled: (text) => onUpdate?.({ content: [{ type: "text", text }], details: { action: "resume", runId: params.runId, stalled: true } }),
156
- onCleanupError: (text) => onUpdate?.({ content: [{ type: "text", text }], details: { action: "resume", runId: params.runId, cleanupError: text } }),
157
- });
158
- return { content: [{ type: "text", text: result.summary }], details: result, ...(result.status === "failed" || result.status === "cancelled" ? { isError: true } : {}) };
159
- } catch (error) { return errorResponse(error instanceof Error ? error.message : String(error)); }
160
- },
161
- async respond({ params, signal, onUpdate, ctx, dependencies }: ToolExecutionContext) {
162
- try {
163
- const result = await respondToBlockedTask(ctx.cwd, (params.runId as string).trim(), (params.answer as string).trim(), {
164
- herdr: dependencies.herdr, taskNumber: params.task as number, signal, owner: dependencies.owner,
165
- onUpdate: (partial) => onUpdate?.({ content: [{ type: "text", text: partial.summary }], details: partial }),
166
- onStalled: (text) => onUpdate?.({ content: [{ type: "text", text }], details: { action: "respond", runId: params.runId, stalled: true } }),
167
- onWarning: (text) => onUpdate?.({ content: [{ type: "text", text }], details: { action: "respond", runId: params.runId, warning: text } }),
168
- onCleanupError: (text) => onUpdate?.({ content: [{ type: "text", text }], details: { action: "respond", runId: params.runId, cleanupError: text } }),
169
- });
170
- const blockedControl = result.status === "blocked" ? { allowedActions: ["respond", "cancel"] as const } : {};
171
- const text = [result.summary, ...(result.question ? [`Question: ${result.question}`] : []), ...(result.status === "blocked" ? [`Run ID: ${result.runId}`, `Answer with herdr_subagents_control({ action: "respond", runId: "${result.runId}", answer: "..." }) or cancel this run.`] : []), ...result.documents.map((document) => `${document.description}: ${document.path}`)].join("\n");
172
- return { content: [{ type: "text", text }], details: { ...result, ...blockedControl }, ...(result.status === "failed" || result.status === "partial" || result.status === "cancelled" ? { isError: true } : {}) };
173
- } catch (error) { return errorResponse(error instanceof Error ? error.message : String(error)); }
174
- },
175
134
  async list({ discovery }: ToolExecutionContext) {
176
135
  return { content: [{ type: "text", text: listText(discovery) }], details: { action: "list", agents: discovery.personas, diagnostics: discovery.diagnostics, settings: discovery.settings } };
177
136
  },
@@ -201,10 +160,9 @@ async function executeBatch({ params, signal, onUpdate, ctx, dependencies, disco
201
160
  onUpdate: (partial) => onUpdate?.({ content: [{ type: "text", text: partial.summary }], details: { ...partial, prompts: tasks.map((task) => task.prompt) } }),
202
161
  });
203
162
  const lines = [result.summary, ...result.tasks.map((task) => `- ${task.name}: ${task.status} — ${task.summary}`)];
204
- if (result.status === "blocked") lines.push("", `Run ID: ${result.runId}`, `Answer with herdr_subagents_control({ action: "respond", runId: "${result.runId}", answer: "..." }) or cancel this run before starting another foreground run.`);
205
163
  if (result.documents.length) lines.push("", "Documents:", ...result.documents.map((document) => `- ${document.description}: ${document.path}`));
206
164
  const isError = result.status === "failed" || result.status === "partial" || result.status === "cancelled";
207
- return { content: [{ type: "text", text: lines.join("\n") }], details: { ...result, prompts: tasks.map((task) => task.prompt), ...(result.status === "blocked" ? { allowedActions: ["respond", "cancel"] } : {}) }, ...(isError ? { isError: true } : {}) };
165
+ return { content: [{ type: "text", text: lines.join("\n") }], details: { ...result, prompts: tasks.map((task) => task.prompt) }, ...(isError ? { isError: true } : {}) };
208
166
  } catch (error) {
209
167
  const text = error instanceof Error ? error.message : String(error);
210
168
  const diagnostic = error as { recordDirectory?: string; runId?: string };
@@ -6,8 +6,6 @@ export const HERDR_ACTIONS = {
6
6
  status: { allowed: ["action", "runId"], includeRunId: true },
7
7
  cancel: { allowed: ["action", "runId"], includeRunId: true },
8
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
9
  } as const satisfies Record<string, { allowed: readonly (keyof HerdrSubagentsControlToolParams)[]; includeRunId: boolean }>;
12
10
 
13
11
  export function validateRunParams(params: HerdrSubagentsRunParams): string | undefined {
@@ -21,7 +19,5 @@ export function validateControlParams(params: HerdrSubagentsControlToolParams):
21
19
  const extra = Object.keys(params).find((key) => !allowed.includes(key as keyof HerdrSubagentsControlToolParams));
22
20
  if (extra) return `Field '${extra}' is not valid with action '${params.action}'.`;
23
21
  if ((params.action === "status" || params.action === "cancel" || params.action === "cleanup") && !params.runId?.trim()) return `runId is required for action '${params.action}'.`;
24
- if (params.action === "respond" && (!params.runId?.trim() || !params.answer?.trim() || !params.task)) return "runId, answer, and task are required for action 'respond'.";
25
- if (params.action === "resume" && (!params.runId?.trim() || !params.task || !params.prompt?.trim())) return "runId, task, and prompt are required for action 'resume'.";
26
22
  return undefined;
27
23
  }
@@ -0,0 +1,178 @@
1
+ import assert from "node:assert/strict";
2
+ import { mkdtempSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { dirname, join } from "node:path";
5
+ import { runHerdrSubagentsBatch } from "../src/batch-runner.ts";
6
+ import registerSubagentReport from "../extensions/subagent-report.ts";
7
+ import { applyTaskStatus } from "../src/records.ts";
8
+
9
+ process.env.HERDR_ENV = "1";
10
+ process.env.HERDR_WORKSPACE_ID = "test-workspace";
11
+
12
+ const root = mkdtempSync(join(tmpdir(), "herdr-state-machine-"));
13
+
14
+ function task(name, prompt, access = "read") {
15
+ return { name, prompt, agent: "worker", access, skills: [], systemPrompt: "Finish the delegated task." };
16
+ }
17
+
18
+ function fakeHerdr() {
19
+ let pane = 0;
20
+ let active = 0;
21
+ let maxActive = 0;
22
+ const alive = new Map();
23
+ const starts = [];
24
+ const paneDirectories = new Map();
25
+ const agents = new Map();
26
+ const publish = (directory, report) => {
27
+ const record = JSON.parse(readFileSync(join(directory, "task.json"), "utf8"));
28
+ const path = join(directory, "reports", `${record.currentAttemptId}.json`);
29
+ mkdirSync(dirname(path), { recursive: true });
30
+ writeFileSync(path, JSON.stringify({ attemptId: record.currentAttemptId, documents: [], reportedAt: new Date().toISOString(), ...report }));
31
+ };
32
+ return {
33
+ get maxActive() { return maxActive; },
34
+ starts,
35
+ async createTab(request) {
36
+ pane += 1;
37
+ const paneId = `pane-${pane}`;
38
+ paneDirectories.set(paneId, request.env.PI_HERDR_SUBAGENTS_TASK_DIR);
39
+ return { tabId: "tab-1", paneId };
40
+ },
41
+ async splitPane(request) {
42
+ pane += 1;
43
+ const paneId = `pane-${pane}`;
44
+ paneDirectories.set(paneId, request.env.PI_HERDR_SUBAGENTS_TASK_DIR);
45
+ return { paneId };
46
+ },
47
+ async startAgent(request) {
48
+ const session = request.args[request.args.indexOf("--session") + 1];
49
+ const directory = dirname(session);
50
+ assert.equal(paneDirectories.get(request.paneId), directory);
51
+ assert.equal(request.args.includes("--print"), false);
52
+ alive.set(request.paneId, true);
53
+ agents.set(request.name, { paneId: request.paneId, directory });
54
+ },
55
+ async promptAgent(request) {
56
+ const { paneId, directory } = agents.get(request.target);
57
+ starts.push(request.prompt);
58
+ active += 1;
59
+ maxActive = Math.max(maxActive, active);
60
+ if (request.prompt.includes("transport retry")) {
61
+ setTimeout(() => {
62
+ publish(directory, { status: "completed", summary: `${request.prompt} result` });
63
+ alive.set(paneId, false);
64
+ active -= 1;
65
+ }, 35);
66
+ throw new Error("WebSocket closed 1000");
67
+ }
68
+ await new Promise((resolve) => setTimeout(resolve, request.prompt.includes("slow") ? 35 : 10));
69
+ if (!request.prompt.includes("omit report")) publish(directory, {
70
+ status: request.prompt.includes("fail") ? "failed" : "completed",
71
+ summary: `${request.prompt} result`,
72
+ ...(request.prompt.includes("fail") ? { error: "expected failure" } : {}),
73
+ });
74
+ alive.set(paneId, false);
75
+ active -= 1;
76
+ return { status: "done" };
77
+ },
78
+ async getAgentState() { return "unknown"; },
79
+ async waitForAgentChange() { await new Promise((resolve) => setTimeout(resolve, 5)); },
80
+ async isTaskRunning(paneId) { return alive.get(paneId) === true; },
81
+ async renamePane() {}, async renameTab() {}, async focusPane() {},
82
+ async isTabFocused() { return false; }, async waitForTabUnfocused() {},
83
+ async paneExists() { return true; }, async closeTab() {},
84
+ };
85
+ }
86
+
87
+ async function run(tasks, concurrency, herdr = fakeHerdr(), signal) {
88
+ const result = await runHerdrSubagentsBatch(tasks, {
89
+ label: "state-machine-test", concurrency, maxConcurrency: 4, cwd: root, herdr, signal,
90
+ });
91
+ return { result, herdr };
92
+ }
93
+
94
+ {
95
+ const { result, herdr } = await run([task("one", "first slow"), task("two", "second")], 1);
96
+ assert.equal(result.status, "completed");
97
+ assert.equal(herdr.maxActive, 1);
98
+ assert.deepEqual(herdr.starts, ["first slow", "second"]);
99
+ assert.deepEqual(result.tasks.map((entry) => entry.status), ["completed", "completed"]);
100
+ }
101
+
102
+ {
103
+ const { result, herdr } = await run([task("one", "one slow"), task("two", "two slow"), task("three", "three")], 2);
104
+ assert.equal(result.status, "completed");
105
+ assert.equal(herdr.maxActive, 2);
106
+ assert.deepEqual(result.tasks.map((entry) => entry.index), [0, 1, 2]);
107
+ }
108
+
109
+ {
110
+ const { result } = await run([task("retry", "retry slow")], 1);
111
+ assert.equal(result.status, "completed");
112
+ assert.equal(result.tasks[0].summary, "retry slow result");
113
+ }
114
+
115
+
116
+ {
117
+ const { result } = await run([task("retry", "transport retry")], 1);
118
+ assert.equal(result.status, "completed");
119
+ assert.equal(result.tasks[0].summary, "transport retry result");
120
+ }
121
+
122
+ {
123
+ const { result } = await run([task("missing", "omit report")], 1);
124
+ assert.equal(result.status, "failed");
125
+ assert.match(result.tasks[0].error, /exited without submitting/);
126
+ }
127
+
128
+ {
129
+ const { result } = await run([task("bad", "fail"), task("good", "still run")], 2);
130
+ assert.equal(result.status, "partial");
131
+ assert.deepEqual(result.tasks.map((entry) => entry.status), ["failed", "completed"]);
132
+ }
133
+
134
+ {
135
+ const { result } = await run([task("bad-one", "fail one"), task("bad-two", "fail two")], 2);
136
+ assert.equal(result.status, "failed");
137
+ assert.deepEqual(result.tasks.map((entry) => entry.status), ["failed", "failed"]);
138
+ }
139
+
140
+ {
141
+ const controller = new AbortController();
142
+ setTimeout(() => controller.abort(new Error("test cancellation")), 8);
143
+ const { result } = await run([task("active", "slow active"), task("queued", "queued")], 1, fakeHerdr(), controller.signal);
144
+ assert.equal(result.status, "cancelled");
145
+ assert.deepEqual(result.activity.map((entry) => entry.status), ["cancelled", "cancelled"]);
146
+ }
147
+
148
+ await assert.rejects(() => run([task("writer", "edit", "write"), task("reader", "read")], 2), /require concurrency 1/);
149
+
150
+ const terminal = { id: "task", runId: "run", order: 1, status: "completed" };
151
+ assert.throws(() => applyTaskStatus(terminal, "running"), /terminal tasks are immutable/);
152
+
153
+ {
154
+ const directory = join(root, "report-idempotency");
155
+ mkdirSync(directory, { recursive: true });
156
+ const attemptId = "11111111-1111-4111-8111-111111111111";
157
+ writeFileSync(join(directory, "task.json"), JSON.stringify({ id: "task", runId: "run", order: 1, status: "running", currentAttemptId: attemptId }));
158
+ const handlers = new Map();
159
+ let reportTool;
160
+ registerSubagentReport({
161
+ registerTool(tool) { reportTool = tool; },
162
+ registerCommand() {}, registerShortcut() {},
163
+ on(name, handler) { handlers.set(name, handler); },
164
+ events: { on() {} },
165
+ async exec() { return { code: 0, stdout: "", stderr: "", killed: false }; },
166
+ });
167
+ process.env.PI_HERDR_SUBAGENTS_TASK_DIR = directory;
168
+ await handlers.get("before_agent_start")?.();
169
+ const report = { status: "completed", summary: "done", result: "full result", documents: [] };
170
+ await reportTool.execute("first", report);
171
+ await reportTool.execute("duplicate", report);
172
+ await assert.rejects(() => reportTool.execute("conflict", { ...report, summary: "different" }), /Conflicting subagent_report/);
173
+ const persisted = JSON.parse(readFileSync(join(directory, "reports", `${attemptId}.json`), "utf8"));
174
+ assert.equal(persisted.summary, "done");
175
+ delete process.env.PI_HERDR_SUBAGENTS_TASK_DIR;
176
+ }
177
+
178
+ console.log("PASS core Herdr state machine");
@@ -37,16 +37,20 @@ try {
37
37
  });
38
38
  }`);
39
39
  const args = childPiArgs({ id: "probe", sessionFile: join(root, "session.jsonl"), tools: ["read"], extensions: [fileExtension, "./local package", probe] }, join(root, "system.md"));
40
+ // `builtin:codemode` is translated by `herdr agent start` and is covered by
41
+ // the real-Herdr smoke test. Direct Pi has no portable form of that alias.
42
+ const codemode = args.indexOf("builtin:codemode");
43
+ args.splice(codemode - 1, 2);
40
44
  writeFileSync(join(root, "system.md"), "Extension source loading probe.");
41
45
  const env = { ...process.env, PI_CODING_AGENT_DIR: join(root, "agent"), PI_HERDR_SUBAGENTS_CHILD: "1" };
42
46
  for (const key of Object.keys(env)) if (key.startsWith("HERDR_") || key === "PI_HERDR_SUBAGENTS_TASK_DIR" || key === "PI_HERDR_SUBAGENTS_PARENT_PANE_ID") delete env[key];
43
- const result = spawnSync(process.env.PI_TEST_BINARY?.trim() || "pi", [...args, "--offline", "--no-mcp"], { cwd: root, env, encoding: "utf8", timeout: 30_000 });
47
+ const result = spawnSync(process.env.PI_TEST_BINARY?.trim() || "pi", [...args, "--offline"], { cwd: root, env, encoding: "utf8", timeout: 30_000 });
44
48
  assert.equal(result.error, undefined, String(result.error));
45
49
  assert.equal(result.status, 0, result.stderr || result.stdout);
46
50
  const loaded = JSON.parse(readFileSync(resultPath, "utf8"));
47
51
  for (const command of ["file-extension", "package-extension", "skill:package-skill", "package-prompt", "parent"]) assert.ok(loaded.commands.includes(command), `Missing ${command}: ${JSON.stringify(loaded)}`);
48
- assert.deepEqual([...loaded.tools].sort(), ["codemode", "read", "subagent_report"]);
49
- console.log("PASS real Pi loads explicit file and package sources, including package skills/prompts despite discovery flags, without broadening the tool allowlist");
52
+ assert.deepEqual([...loaded.tools].sort(), ["read", "subagent_report"]);
53
+ console.log("PASS real Pi loads explicit file and package sources, including package skills/prompts despite discovery flags, without broadening the direct-Pi tool allowlist");
50
54
  } finally {
51
55
  rmSync(root, { recursive: true, force: true });
52
56
  }