@maxiaochao/pi-toolkit 0.11.5 → 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.
@@ -5,15 +5,13 @@ import { Container, Editor, Text } from "@earendil-works/pi-tui";
5
5
  import { loadSubagentConfiguration } from "../src/config.ts";
6
6
  import { CliHerdrAutomation, type HerdrAutomation } from "../src/herdr.ts";
7
7
  import { activeRunText } from "../src/monitor.ts";
8
- import { RunDispatcher } from "../src/dispatcher.ts";
9
8
  import { historyText, listSubagentHistory } from "../src/history.ts";
10
- import { cancelQueuedRuns, loadQueuedRuns, persistQueuedRun, removeQueuedRun } from "../src/queued-runs.ts";
9
+ import { cancelQueuedRuns, loadQueuedRuns } from "../src/queued-runs.ts";
11
10
  import { cancelActiveSubagentRuns, reconcileSubagentRuns } from "../src/reconcile.ts";
12
11
  import { executeHerdrSubagents, executeHerdrSubagentsControl, type HerdrSubagentsBatchDetails, type HerdrSubagentsControlToolParams, type HerdrSubagentsRunParams } from "../src/tool.ts";
13
12
  import { HerdrSubagentsControlParams, HerdrSubagentsParams } from "../src/parameters.ts";
14
13
  import { HERDR_ACTIONS } from "../src/validation.ts";
15
14
  import { FleetController, type FleetWidgetRuntime } from "../src/fleet-controller.ts";
16
- import { isSettledRunStatus } from "../src/records.ts";
17
15
 
18
16
  const packageAgentsDir = fileURLToPath(new URL("../agents/", import.meta.url));
19
17
 
@@ -39,7 +37,7 @@ export function normalizeHerdrSubagentsArguments(args: unknown): HerdrSubagentsR
39
37
  if (!args || typeof args !== "object") return args as HerdrSubagentsRunParams;
40
38
  const legacy = args as Record<string, unknown>;
41
39
  if (Array.isArray(legacy.tasks) || typeof legacy.prompt !== "string" || legacy.action !== undefined) return args as HerdrSubagentsRunParams;
42
- if (Object.keys(legacy).some((key) => !["prompt", "agent", "label", "model", "background"].includes(key))) return args as HerdrSubagentsRunParams;
40
+ if (Object.keys(legacy).some((key) => !["prompt", "agent", "label", "model"].includes(key))) return args as HerdrSubagentsRunParams;
43
41
  const label = typeof legacy.label === "string" ? legacy.label : undefined;
44
42
  return {
45
43
  tasks: [{
@@ -49,7 +47,6 @@ export function normalizeHerdrSubagentsArguments(args: unknown): HerdrSubagentsR
49
47
  ...(typeof legacy.model === "string" ? { model: legacy.model } : {}),
50
48
  }],
51
49
  ...(label ? { label } : {}),
52
- ...(typeof legacy.background === "boolean" ? { background: legacy.background } : {}),
53
50
  };
54
51
  }
55
52
 
@@ -124,17 +121,12 @@ async function recoverParentSession(cwd: string, herdr: HerdrAutomation, owner:
124
121
  catch (error) { notify(`Subagent recovery could not inspect active runs: ${error instanceof Error ? error.message : String(error)}`); }
125
122
  }
126
123
 
127
- async function shutdownParentSession(options: { cwd: string; herdr: HerdrAutomation; owner: { sessionId?: string; processId: number }; dispatcher: RunDispatcher; fleet: FleetController; sessionController: AbortController; runControllers: Map<string, AbortController>; inFlight: Set<Promise<unknown>>; notify: (message: string) => void }): Promise<void> {
128
- options.dispatcher.pause();
124
+ async function shutdownParentSession(options: { cwd: string; herdr: HerdrAutomation; owner: { sessionId?: string; processId: number }; fleet: FleetController; sessionController: AbortController; runControllers: Map<string, AbortController>; inFlight: Set<Promise<unknown>>; notify: (message: string) => void }): Promise<void> {
129
125
  options.sessionController.abort(new Error("Parent Pi session closed."));
130
- const queuedRunIds = options.dispatcher.cancelQueued(new Error("Parent Pi session closed."));
131
- await cancelQueuedRuns(options.cwd, queuedRunIds, { reason: "Parent Pi session closed.", owner: options.owner });
132
126
  for (const controller of options.runControllers.values()) controller.abort(new Error("Parent Pi session closed."));
133
127
  options.fleet.shutdown();
134
128
  await Promise.allSettled([...options.inFlight]);
135
129
  const cancellation = await cancelActiveSubagentRuns(options.cwd, options.herdr, { reason: "Parent Pi session closed.", owner: options.owner });
136
- const retainedRunId = options.dispatcher.snapshot().activeRunId;
137
- if (retainedRunId) options.dispatcher.release(retainedRunId);
138
130
  for (const message of cancellation.cleanupErrors) options.notify(message);
139
131
  }
140
132
 
@@ -144,168 +136,58 @@ export function registerHerdrSubagents(pi: ExtensionAPI, dependencies: HerdrSuba
144
136
  const execute = dependencies.execute ?? executeHerdrSubagents;
145
137
  const executeControl = dependencies.executeControl ?? executeHerdrSubagentsControl;
146
138
  const isEditorComponent = dependencies.isEditor ?? ((value: unknown) => value instanceof Editor);
147
- const dispatcher = new RunDispatcher();
148
139
  const fleet = new FleetController(herdr, isEditorComponent, dependencies.createFleetWidget, dependencies.fleetWidgetRuntime);
149
140
  const runControllers = new Map<string, AbortController>();
150
141
  const runPromises = new Map<string, Promise<unknown>>();
151
142
  const inFlight = new Set<Promise<unknown>>();
152
143
  let sessionController = new AbortController();
153
144
  let ownerSessionId: string | undefined;
154
- let sessionGeneration = 0;
155
- const notifyRun = (runId: string, result: { details: unknown; content?: Array<{ type: string; text?: string }> }) => {
156
- const details = result.details as Partial<HerdrSubagentsBatchDetails> & { errorMessage?: string };
157
- if (!details.tasks || !details.documents || !details.status) {
158
- const error = details.errorMessage ?? result.content?.find((item) => item.type === "text")?.text ?? "Unknown Subagent failure.";
159
- pi.sendMessage({ customType: "herdr-subagents-run", content: `Subagent run ${runId} failed: ${error}`, display: true, details: { runId, status: "failed" } }, { triggerTurn: true, deliverAs: "followUp" });
160
- return;
161
- }
162
- const lines = [`Subagent run ${runId} ${details.status}: ${details.summary}`, ...details.tasks.map((task) => `- ${task.name}: ${task.summary}`)];
163
- if (details.documents.length) lines.push("Documents:", ...details.documents.map((document) => `- ${document.description}: ${document.path}`));
164
- pi.sendMessage({ customType: "herdr-subagents-run", content: lines.join("\n"), display: true, details: { runId, status: details.status } }, { triggerTurn: true, deliverAs: "followUp" });
165
- };
166
145
 
167
146
  pi.on("session_shutdown", async (_event, ctx) => {
168
- sessionGeneration += 1;
169
- await shutdownParentSession({ cwd: ctx.cwd, herdr, owner: { sessionId: ownerSessionId, processId: process.pid }, dispatcher, fleet, sessionController, runControllers, inFlight, notify: (message) => ctx.ui.notify(message, "error") });
147
+ await shutdownParentSession({ cwd: ctx.cwd, herdr, owner: { sessionId: ownerSessionId, processId: process.pid }, fleet, sessionController, runControllers, inFlight, notify: (message) => ctx.ui.notify(message, "error") });
170
148
  });
171
149
  pi.on("session_start", async (_event, ctx) => {
172
- sessionGeneration += 1;
173
150
  sessionController.abort(new Error("Parent Pi session changed."));
174
151
  sessionController = new AbortController();
175
152
  ownerSessionId = `${ctx.sessionManager.getSessionId()}:${randomUUID()}`;
176
153
  fleet.bind(ctx.ui, ctx.mode === "tui");
177
- dispatcher.pause();
178
- try { await recoverParentSession(ctx.cwd, herdr, { sessionId: ownerSessionId, processId: process.pid }, (message) => ctx.ui.notify(message, "error")); }
179
- finally { dispatcher.resume(); }
154
+ await recoverParentSession(ctx.cwd, herdr, { sessionId: ownerSessionId, processId: process.pid }, (message) => ctx.ui.notify(message, "error"));
180
155
  });
181
156
 
182
157
  pi.registerTool({
183
158
  name: "herdr_subagents",
184
159
  label: "Herdr Subagents",
185
- description: "Run one or more ordered tasks in visible interactive Herdr Pi Agents. Use one tasks item for a single task. Independent read-only tasks may run concurrently; write-capable runs are serialized. A task that needs the parent agent to answer or decide something reports blocked at any concurrency, keeps its live pane, and lets its independent siblings continue; answer it with herdr_subagents_control respond, which a blocked run waits for before another foreground run can start. Foreground calls wait for the batch unless a task fails, when the run continues in the background and returns its run ID immediately. Explicit background calls always return a run ID immediately. Complete results and Pi sessions stay in project-local records.",
160
+ description: "Run one or more ordered tasks in visible, focusable Herdr Pi panes. concurrency 1 is strict serial execution; higher values form a bounded pool for read-only tasks. Write-capable tasks require concurrency 1. The call returns only after every task has one terminal completed, failed, or cancelled result. Complete results and Pi sessions stay in project-local records.",
186
161
  promptSnippet: "Run focused work in visible Herdr subagents",
187
162
  parameters: HerdrSubagentsParams,
188
163
  prepareArguments: normalizeHerdrSubagentsArguments,
189
164
 
190
165
  async execute(_toolCallId, params: HerdrSubagentsRunParams, signal, onUpdate, ctx) {
191
- const blockedForegroundResponse = (blockingRunId: string) => {
192
- const text = `Cannot start a foreground Subagent run while blocked run ${blockingRunId} is waiting for input. Respond to or cancel that run first, or submit this run with background: true.`;
193
- return { content: [{ type: "text" as const, text }], details: { errorMessage: text, blockingRunId }, isError: true };
194
- };
195
- const blockingRunId = dispatcher.snapshot().retainedRunId;
196
- if (!params.background && blockingRunId) return blockedForegroundResponse(blockingRunId);
197
- const generation = sessionGeneration;
198
166
  const runId = randomUUID();
199
167
  const controller = new AbortController();
200
- const foregroundController = new AbortController();
201
- let foregroundAttached = !params.background;
202
- const abortForeground = () => {
203
- if (foregroundAttached && signal?.aborted) foregroundController.abort(signal.reason);
204
- };
205
- if (foregroundAttached) {
206
- if (signal?.aborted) abortForeground();
207
- else signal?.addEventListener("abort", abortForeground, { once: true });
208
- }
168
+ const runSignal = AbortSignal.any([controller.signal, sessionController.signal, ...(signal ? [signal] : [])]);
209
169
  runControllers.set(runId, controller);
210
- const runSignal = AbortSignal.any([controller.signal, sessionController.signal, ...(!params.background ? [foregroundController.signal] : [])]);
211
- let resolveEarlyFailure: ((details: HerdrSubagentsBatchDetails) => void) | undefined;
212
- const earlyFailure = new Promise<HerdrSubagentsBatchDetails>((resolve) => { resolveEarlyFailure = resolve; });
213
- let failureObserved = false;
214
- let runSettled = false;
215
- const executeRun = async () => {
216
- const result = await execute(params, runSignal, (update) => {
217
- const details = update.details as HerdrSubagentsBatchDetails | undefined;
218
- if (details?.activity) fleet.sync(details);
219
- const hasUnfinishedSibling = details?.activity?.some((task) => task.status === "queued" || task.status === "running");
220
- if (!params.background && !failureObserved && details?.effectiveConcurrency > 1 && hasUnfinishedSibling && details.tasks?.some((task) => task.status === "failed")) {
221
- failureObserved = true;
222
- foregroundAttached = false;
223
- signal?.removeEventListener("abort", abortForeground);
224
- resolveEarlyFailure?.(details);
225
- }
226
- const updateText = update.content.find((item) => item.type === "text")?.text;
227
- const updateDetails = update.details as { warning?: string };
228
- if (updateDetails.warning || updateText?.includes("cleanup failed") || (!foregroundAttached && updateText?.includes("appears stalled"))) ctx.ui.notify(updateDetails.warning ?? updateText ?? "Subagent warning", "warning");
229
- if (foregroundAttached) onUpdate?.(update);
230
- }, ctx, { agentsDirectory: packageAgentsDir, owner: { sessionId: ownerSessionId, processId: process.pid }, runId });
231
- fleet.sync(result.details);
232
- return result;
233
- };
234
- if (params.background) await persistQueuedRun(ctx.cwd, runId, params, { sessionId: ownerSessionId, processId: process.pid });
235
- const promise = dispatcher.submit(runId, async () => {
236
- const result = await executeRun();
237
- const details = result.details as HerdrSubagentsBatchDetails;
238
- const ownsDispatcher = dispatcher.snapshot().activeRunId === runId;
239
- if (ownsDispatcher) fleet.setActive(details.status === "blocked" ? details : undefined);
240
- else if (details.status === "blocked") fleet.clearRun(runId);
241
- if (ownsDispatcher && fleet.active) {
242
- dispatcher.retain(runId);
243
- }
244
- return result;
245
- }, params.background ? undefined : { ...(signal ? { signal } : {}), onRetained: blockedForegroundResponse });
170
+ const promise = execute(params, runSignal, (update) => {
171
+ const details = update.details as HerdrSubagentsBatchDetails | undefined;
172
+ if (details?.activity) fleet.sync(details);
173
+ onUpdate?.(update);
174
+ }, ctx, {
175
+ agentsDirectory: packageAgentsDir,
176
+ herdr,
177
+ owner: { sessionId: ownerSessionId, processId: process.pid },
178
+ runId,
179
+ });
246
180
  runPromises.set(runId, promise);
247
181
  inFlight.add(promise);
248
- void promise.finally(() => {
249
- runSettled = true;
250
- foregroundAttached = false;
251
- signal?.removeEventListener("abort", abortForeground);
182
+ try {
183
+ const result = await promise;
184
+ fleet.sync(result.details);
185
+ return result;
186
+ } finally {
252
187
  inFlight.delete(promise);
253
- runControllers.delete(runId);
254
188
  runPromises.delete(runId);
255
- }).catch(() => undefined);
256
- let backgroundLifecycleStarted = false;
257
- const startBackgroundLifecycle = () => {
258
- if (backgroundLifecycleStarted) return;
259
- backgroundLifecycleStarted = true;
260
- const lifecycle = promise.then((result) => {
261
- if (generation !== sessionGeneration) return;
262
- notifyRun(runId, result);
263
- if (!fleet.active && !dispatcher.snapshot().activeRunId && dispatcher.snapshot().queuedRunIds.length === 0) fleet.render();
264
- }).catch((error) => {
265
- if (generation !== sessionGeneration) return;
266
- if (controller.signal.aborted) {
267
- pi.sendMessage({ customType: "herdr-subagents-run", content: `Subagent run ${runId} cancelled.`, display: true, details: { runId, status: "cancelled" } }, { triggerTurn: true, deliverAs: "followUp" });
268
- return;
269
- }
270
- pi.sendMessage({ customType: "herdr-subagents-run", content: `Subagent run ${runId} failed: ${error instanceof Error ? error.message : String(error)}`, display: true, details: { runId, status: "failed" } }, { triggerTurn: true, deliverAs: "followUp" });
271
- }).finally(() => removeQueuedRun(ctx.cwd, runId));
272
- inFlight.add(lifecycle);
273
- void lifecycle.finally(() => inFlight.delete(lifecycle)).catch(() => undefined);
274
- };
275
- if (params.background) {
276
- startBackgroundLifecycle();
277
- return { content: [{ type: "text" as const, text: `Subagent run queued: ${runId}` }], details: { runId, status: "queued", background: true } };
278
- }
279
- const foregroundOutcome = await Promise.race([
280
- promise.then((result) => ({ kind: "settled" as const, result })),
281
- earlyFailure.then((details) => ({ kind: "failed" as const, details })),
282
- ]);
283
- if (foregroundOutcome.kind === "failed") {
284
- try {
285
- await persistQueuedRun(ctx.cwd, runId, params, { sessionId: ownerSessionId, processId: process.pid });
286
- } catch (error) {
287
- controller.abort(new Error("Could not persist failed foreground run for background continuation."));
288
- await promise.catch(() => undefined);
289
- await removeQueuedRun(ctx.cwd, runId).catch(() => undefined);
290
- throw error;
291
- }
292
- if (runSettled) {
293
- await removeQueuedRun(ctx.cwd, runId);
294
- return promise;
295
- }
296
- dispatcher.detach(runId);
297
- startBackgroundLifecycle();
298
- const failedTask = foregroundOutcome.details.tasks.find((task) => task.status === "failed");
299
- const failure = failedTask?.error ?? failedTask?.summary ?? "Unknown task failure.";
300
- return {
301
- content: [{ type: "text" as const, text: `Subagent run ${runId} continues in the background after ${failedTask?.name ?? "a task"} failed: ${failure}` }],
302
- details: { ...foregroundOutcome.details, runId, background: true, earlyReturn: true, failedTask: failedTask ? { index: failedTask.index, name: failedTask.name, summary: failedTask.summary, ...(failedTask.error ? { error: failedTask.error } : {}) } : undefined },
303
- isError: true,
304
- };
189
+ runControllers.delete(runId);
305
190
  }
306
- const result = foregroundOutcome.result;
307
- if (!fleet.active && !dispatcher.snapshot().activeRunId && dispatcher.snapshot().queuedRunIds.length === 0) fleet.resetSelection();
308
- return result;
309
191
  },
310
192
 
311
193
  renderCall: renderToolCall,
@@ -315,7 +197,7 @@ export function registerHerdrSubagents(pi: ExtensionAPI, dependencies: HerdrSuba
315
197
  pi.registerTool({
316
198
  name: "herdr_subagents_control",
317
199
  label: "Herdr Subagents Control",
318
- description: "Inspect Herdr personas, query one run by ID, list run history, cancel active or queued work, answer a blocked task or continue a failed one with respond, resume a saved task, or remove a selected archived run. Continuing a failed task requires its task number and works while the rest of its batch still runs. Use status for one run and history only for the full archive. Use herdr_subagents to start new work.",
200
+ description: "Inspect Herdr personas, query one run by ID, list run history, cancel active work, or remove an archived run. Use herdr_subagents to start new work.",
319
201
  parameters: HerdrSubagentsControlParams,
320
202
 
321
203
  async execute(_toolCallId, params: HerdrSubagentsControlToolParams, signal, onUpdate, ctx) {
@@ -338,22 +220,15 @@ export function registerHerdrSubagents(pi: ExtensionAPI, dependencies: HerdrSuba
338
220
  const reason = new Error("Subagent run cancelled by user.");
339
221
  const targetController = runControllers.get(runId);
340
222
  targetController?.abort(reason);
341
- if (dispatcher.cancelQueuedRun(runId, reason)) {
342
- await cancelQueuedRuns(ctx.cwd, [runId], { reason: reason.message, owner: { sessionId: ownerSessionId, processId: process.pid } });
343
- fleet.clearRun(runId);
344
- return { cancelled: true, queued: true };
345
- }
346
223
  const activePromise = runPromises.get(runId);
347
224
  if (activePromise) {
348
225
  await activePromise.catch(() => undefined);
349
226
  fleet.clearRun(runId);
350
- dispatcher.release(runId);
351
227
  return { cancelled: true };
352
228
  }
353
229
  const cancellation = await cancelActiveSubagentRuns(ctx.cwd, herdr, { reason: reason.message, owner: { sessionId: ownerSessionId, processId: process.pid }, runId });
354
230
  if (cancellation.cancelledRuns) {
355
231
  fleet.clearRun(runId);
356
- dispatcher.release(runId);
357
232
  }
358
233
  return { cancelled: cancellation.cancelledRuns > 0, cleanupErrors: cancellation.cleanupErrors };
359
234
  },
@@ -361,15 +236,6 @@ export function registerHerdrSubagents(pi: ExtensionAPI, dependencies: HerdrSuba
361
236
  inFlight.add(promise);
362
237
  const result = await promise.finally(() => { inFlight.delete(promise); runControllers.delete(executionId); });
363
238
  fleet.sync(result.details);
364
- if (params.action === "respond") {
365
- const details = result.details as { runId?: string; status?: string };
366
- if (details.status === "blocked") fleet.sync(result.details);
367
- else if (isSettledRunStatus(details.status)) {
368
- const canonicalRunId = details.runId ?? params.runId;
369
- fleet.clearRun(canonicalRunId);
370
- if (canonicalRunId) dispatcher.release(canonicalRunId);
371
- }
372
- }
373
239
  return result;
374
240
  },
375
241
 
@@ -1,4 +1,4 @@
1
- import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
1
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
2
2
  import net from "node:net";
3
3
  import { dirname, resolve } from "node:path";
4
4
  import { StringEnum } from "@earendil-works/pi-ai";
@@ -23,7 +23,7 @@ async function returnToParent(pi: ExtensionAPI, ctx: { ui: { notify(message: str
23
23
  }
24
24
 
25
25
  const ReportParams = Type.Object({
26
- status: StringEnum(["completed", "blocked", "failed"] as const, { description: "Outcome of this turn. completed: the task is finished. blocked: you have a specific question or decision the parent agent must answer before you can continue, and the run should stay open waiting for it. failed: this task cannot be completed, so the run should not wait for an answer." }),
26
+ status: StringEnum(["completed", "failed"] as const, { description: "Terminal task outcome. completed: the task is finished. failed: the task cannot be completed." }),
27
27
  summary: Type.String({ minLength: 1, description: "One concise paragraph for the parent agent." }),
28
28
  result: Type.String({ description: "Complete final result. Stored on disk and excluded from the parent context." }),
29
29
  documents: Type.Optional(Type.Array(Type.Object({
@@ -31,7 +31,6 @@ const ReportParams = Type.Object({
31
31
  description: Type.String({ minLength: 1, description: "Short description of the document." }),
32
32
  }))),
33
33
  error: Type.Optional(Type.String({ minLength: 1, description: "Failure detail when status is failed." })),
34
- question: Type.Optional(Type.String({ minLength: 1, description: "Exact question for the parent agent when status is blocked." })),
35
34
  });
36
35
 
37
36
  function writeAtomic(path: string, content: string): void {
@@ -74,7 +73,14 @@ export default function (pi: ExtensionAPI) {
74
73
  pi.on("agent_start", () => reportHerdrState("working"));
75
74
  pi.on("agent_settled", (_event, ctx) => {
76
75
  const taskDirectory = process.env.PI_HERDR_SUBAGENTS_TASK_DIR?.trim();
77
- if (taskDirectory && activeAttemptId) writeAttemptState(taskDirectory, activeAttemptId, "settled");
76
+ if (taskDirectory && activeAttemptId) {
77
+ writeAttemptState(taskDirectory, activeAttemptId, "settled");
78
+ const reported = existsSync(attemptReportPath(taskDirectory, activeAttemptId));
79
+ // agent_settled is Pi's final automatic-work boundary. Ending the child here
80
+ // gives the orchestrator an authoritative no-report process-exit signal while
81
+ // still allowing all provider auto-retries to finish first.
82
+ setTimeout(() => process.exit(reported ? 0 : 2), 25);
83
+ }
78
84
  if (ctx?.isIdle?.() === true) reportHerdrState("idle");
79
85
  });
80
86
  pi.events.on("herdr:blocked", (data: { active?: boolean; label?: string }) => {
@@ -103,7 +109,7 @@ export default function (pi: ExtensionAPI) {
103
109
  const attemptId = activeAttemptId ?? currentAttemptId(taskDirectory);
104
110
  mkdirSync(taskDirectory, { recursive: true, mode: 0o700 });
105
111
  const resultPath = resultPathForAttemptId(taskDirectory, attemptId);
106
- writeAtomic(resultPath, params.result);
112
+ const reportPath = attemptReportPath(taskDirectory, attemptId);
107
113
  const documents = (params.documents ?? []).map((document) => ({
108
114
  path: resolve(process.cwd(), document.path),
109
115
  description: document.description,
@@ -117,10 +123,18 @@ export default function (pi: ExtensionAPI) {
117
123
  summary: params.summary,
118
124
  documents,
119
125
  ...(params.error ? { error: params.error } : {}),
120
- ...(params.question ? { question: params.question } : {}),
121
126
  reportedAt: new Date().toISOString(),
122
127
  };
123
- writeAtomic(attemptReportPath(taskDirectory, attemptId), `${JSON.stringify(report, null, 2)}\n`);
128
+ if (existsSync(reportPath)) {
129
+ const existing = JSON.parse(readFileSync(reportPath, "utf8")) as Record<string, unknown>;
130
+ const comparable = ({ reportedAt: _reportedAt, ...value }: Record<string, unknown>) => value;
131
+ const sameReport = JSON.stringify(comparable(existing)) === JSON.stringify(comparable(report));
132
+ const sameResult = existsSync(resultPath) && readFileSync(resultPath, "utf8") === params.result;
133
+ if (!sameReport || !sameResult) throw new Error("Conflicting subagent_report already exists for this task.");
134
+ } else {
135
+ writeAtomic(resultPath, params.result);
136
+ writeAtomic(reportPath, `${JSON.stringify(report, null, 2)}\n`);
137
+ }
124
138
  return {
125
139
  content: [{ type: "text" as const, text: `${params.status}: ${params.summary}` }],
126
140
  details: report,
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@maxiaochao/pi-herdr-subagents",
3
- "version": "0.4.14",
3
+ "version": "0.5.0",
4
4
  "description": "Run inspectable interactive Pi subagent batches through Herdr.",
5
5
  "type": "module",
6
6
  "keywords": [
@@ -37,9 +37,10 @@
37
37
  "typebox": "*"
38
38
  },
39
39
  "scripts": {
40
- "test": "node --experimental-strip-types tests/run-tests.mjs && node --experimental-strip-types tests/cleanup-timing.mjs",
40
+ "test": "node --experimental-strip-types tests/core-state-machine.mjs",
41
41
  "test:real-herdr": "node --experimental-strip-types tests/real-herdr-smoke.mjs",
42
- "load-test": "env -u PI_HERDR_SUBAGENTS_CHILD \"${PI_TEST_BINARY:-pi}\" -ne -ns --no-session -e ./tests/extension-contract.ts && env -u PI_HERDR_SUBAGENTS_CHILD \"${PI_TEST_BINARY:-pi}\" -ne -ns --no-session -e ./extensions/herdr-subagents.ts && node --experimental-strip-types tests/extension-sources-load.mjs"
42
+ "load-test": "env -u PI_HERDR_SUBAGENTS_CHILD \"${PI_TEST_BINARY:-pi}\" -ne -ns --no-session -e ./tests/extension-contract.ts && env -u PI_HERDR_SUBAGENTS_CHILD \"${PI_TEST_BINARY:-pi}\" -ne -ns --no-session -e ./extensions/herdr-subagents.ts && node --experimental-strip-types tests/extension-sources-load.mjs",
43
+ "test:legacy": "node --experimental-strip-types tests/run-tests.mjs && node --experimental-strip-types tests/cleanup-timing.mjs"
43
44
  },
44
45
  "publishConfig": {
45
46
  "access": "public"
@@ -91,7 +91,10 @@ export async function runHerdrSubagentsBatch(tasks: BatchTask[], options: BatchO
91
91
  if (process.env.HERDR_ENV !== "1" || !process.env.HERDR_WORKSPACE_ID) throw new Error("Herdr Subagents must run from a Pi session inside Herdr.");
92
92
  const runId = options.runId ?? randomUUID();
93
93
  const requestedConcurrency = Math.max(1, Math.floor(options.concurrency));
94
- const effectiveConcurrency = tasks.some((task) => task.access === "write") ? 1 : Math.min(requestedConcurrency, options.maxConcurrency, tasks.length);
94
+ if (requestedConcurrency > 1 && tasks.some((task) => task.access === "write")) {
95
+ throw new Error("Write-capable tasks require concurrency 1; parallel writes are not safe.");
96
+ }
97
+ const effectiveConcurrency = Math.min(requestedConcurrency, options.maxConcurrency, tasks.length);
95
98
  const runDirectory = join(options.cwd, ".pi", "herdr-subagents", "runs", `${timestamp().replace(/[-:.]/g, "").replace("Z", "Z-")}${runId.slice(0, 8)}`);
96
99
  const runFile = join(runDirectory, "run.json");
97
100
  await ensureRuntimeIgnored(options.cwd);
@@ -113,7 +116,7 @@ export async function runHerdrSubagentsBatch(tasks: BatchTask[], options: BatchO
113
116
  return { taskId, taskDirectory, sessionFile, reportFile: join(taskDirectory, "report.json"), taskFile, systemPromptFile, taskRecord };
114
117
  }));
115
118
  const runRecord: RunRecord = {
116
- id: runId, label: options.label, status: "starting", cwd: options.cwd, ...ownerRecord(options.owner), requestedConcurrency,
119
+ id: runId, label: options.label, status: "running", cwd: options.cwd, ...ownerRecord(options.owner), requestedConcurrency,
117
120
  effectiveConcurrency, stalledWarningSeconds: options.stalledWarningSeconds, startedAt: timestamp(), taskIds: contexts.map((context) => context.taskId), paneIds: [],
118
121
  };
119
122
  await writeJsonAtomic(runFile, runRecord);
@@ -205,7 +208,6 @@ export async function runHerdrSubagentsBatch(tasks: BatchTask[], options: BatchO
205
208
  const index = nextIndex++;
206
209
  if (index >= tasks.length) return;
207
210
  await runOne(index);
208
- if (results[index]?.status === "blocked") return;
209
211
  }
210
212
  };
211
213
  const workers = await Promise.allSettled(Array.from({ length: effectiveConcurrency }, worker));
@@ -39,6 +39,7 @@ export interface HerdrAutomation {
39
39
  startAgent(request: StartAgentRequest): Promise<void>;
40
40
  promptAgent(request: PromptAgentRequest): Promise<{ status?: string }>;
41
41
  getAgentState(target: string, signal?: AbortSignal): Promise<HerdrAgentState>;
42
+ waitForAgentChange?(target: string, signal?: AbortSignal): Promise<void>;
42
43
  splitPane(request: SplitPaneRequest): Promise<{ paneId: string }>;
43
44
  renamePane(paneId: string, label: string, signal?: AbortSignal): Promise<void>;
44
45
  renameTab(tabId: string, label: string, signal?: AbortSignal): Promise<void>;
@@ -165,6 +166,16 @@ export class CliHerdrAutomation implements HerdrAutomation {
165
166
  return state === "working" || state === "blocked" || state === "idle" || state === "done" ? state : "unknown";
166
167
  }
167
168
 
169
+ async waitForAgentChange(target: string, signal?: AbortSignal): Promise<void> {
170
+ try {
171
+ await runCommand(this.binary, ["agent", "wait", target, "--until", "idle", "--until", "blocked", "--until", "done", "--until", "unknown", "--timeout", "500"], signal);
172
+ await new Promise<void>((resolve) => setTimeout(resolve, 100));
173
+ } catch (error) {
174
+ if (error instanceof HerdrCommandError && /timed?\s*out|timeout/i.test(`${error.message}\n${error.stderr}`)) return;
175
+ throw error;
176
+ }
177
+ }
178
+
168
179
  async splitPane(request: SplitPaneRequest): Promise<{ paneId: string }> {
169
180
  const args = ["pane", "split", request.paneId, "--direction", request.direction, "--cwd", request.cwd, request.focus ? "--focus" : "--no-focus"];
170
181
  for (const [key, value] of Object.entries(request.env ?? {}).sort(([left], [right]) => left.localeCompare(right))) args.push("--env", `${key}=${value}`);