@maxiaochao/pi-toolkit 0.10.20 → 0.10.22

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 (33) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/package.json +1 -1
  3. package/packages/herdr-subagents/CHANGELOG.md +26 -0
  4. package/packages/herdr-subagents/README.md +21 -7
  5. package/packages/herdr-subagents/RESUME-LIFECYCLE-DESIGN.md +84 -0
  6. package/packages/herdr-subagents/TESTING.md +15 -2
  7. package/packages/herdr-subagents/extensions/herdr-subagents.ts +15 -21
  8. package/packages/herdr-subagents/extensions/subagent-report.ts +2 -2
  9. package/packages/herdr-subagents/package.json +1 -1
  10. package/packages/herdr-subagents/src/batch-runner.ts +58 -11
  11. package/packages/herdr-subagents/src/blocking.ts +115 -72
  12. package/packages/herdr-subagents/src/continue-run.ts +21 -4
  13. package/packages/herdr-subagents/src/dispatcher.ts +44 -4
  14. package/packages/herdr-subagents/src/fleet.ts +2 -1
  15. package/packages/herdr-subagents/src/herdr.ts +10 -1
  16. package/packages/herdr-subagents/src/historical-resume.ts +184 -0
  17. package/packages/herdr-subagents/src/history.ts +11 -128
  18. package/packages/herdr-subagents/src/live-agent.ts +99 -35
  19. package/packages/herdr-subagents/src/parameters.ts +2 -2
  20. package/packages/herdr-subagents/src/protocol.ts +3 -1
  21. package/packages/herdr-subagents/src/reconcile.ts +156 -123
  22. package/packages/herdr-subagents/src/records.ts +50 -6
  23. package/packages/herdr-subagents/src/resume-state.ts +46 -0
  24. package/packages/herdr-subagents/src/run-locator.ts +17 -2
  25. package/packages/herdr-subagents/src/run-pane-allocator.ts +39 -37
  26. package/packages/herdr-subagents/src/run-state.ts +120 -0
  27. package/packages/herdr-subagents/src/status.ts +8 -3
  28. package/packages/herdr-subagents/src/tab-cleanup.ts +16 -16
  29. package/packages/herdr-subagents/src/tool.ts +14 -62
  30. package/packages/herdr-subagents/src/validation.ts +1 -1
  31. package/packages/herdr-subagents/tests/extension-contract.ts +78 -3
  32. package/packages/herdr-subagents/tests/run-tests.mjs +608 -64
  33. package/packages/herdr-subagents/src/runner.ts +0 -234
@@ -0,0 +1,120 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { lstat, mkdir, readFile, rename, rm } from "node:fs/promises";
3
+ import { dirname, join } from "node:path";
4
+ import { isProcessAlive } from "./ownership.ts";
5
+ import { readRunRecord, type RunRecord } from "./records.ts";
6
+ import { writeJsonAtomic } from "./state.ts";
7
+
8
+ interface LockOwner {
9
+ token: string;
10
+ processId: number;
11
+ createdAt: string;
12
+ }
13
+
14
+ export interface DirectoryLock {
15
+ directory: string;
16
+ token: string;
17
+ release(): Promise<void>;
18
+ }
19
+
20
+ const INITIALIZATION_STALE_MS = 30_000;
21
+ const delay = (milliseconds: number, signal?: AbortSignal) => new Promise<void>((resolve, reject) => {
22
+ const onAbort = () => { clearTimeout(timer); reject(signal?.reason ?? new Error("Lock wait aborted.")); };
23
+ const timer = setTimeout(() => { signal?.removeEventListener("abort", onAbort); resolve(); }, milliseconds);
24
+ if (signal?.aborted) onAbort();
25
+ else signal?.addEventListener("abort", onAbort, { once: true });
26
+ });
27
+
28
+ async function readOwner(directory: string): Promise<LockOwner | undefined> {
29
+ try {
30
+ const value = JSON.parse(await readFile(join(directory, "owner.json"), "utf8")) as Partial<LockOwner>;
31
+ if (typeof value.token === "string" && Number.isInteger(value.processId)) return { ...value, createdAt: typeof value.createdAt === "string" ? value.createdAt : new Date(0).toISOString() } as LockOwner;
32
+ } catch { /* An invalid/legacy lock is handled only after its initialization lease expires. */ }
33
+ return undefined;
34
+ }
35
+
36
+ async function reclaimIfStale(directory: string): Promise<boolean> {
37
+ const owner = await readOwner(directory);
38
+ if (owner && isProcessAlive(owner.processId)) return false;
39
+ let identity;
40
+ try { identity = await lstat(directory); }
41
+ catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return true; throw error; }
42
+ // A missing owner is an explicit initialization state. Atomic publication below does
43
+ // not create it, but retain a lease for old/crashed lock directories.
44
+ if (!owner && Date.now() - identity.mtimeMs < INITIALIZATION_STALE_MS) return false;
45
+ const stale = `${directory}.stale-${randomUUID()}`;
46
+ // Revalidate both ownership and directory identity immediately before reclamation.
47
+ const latestOwner = await readOwner(directory);
48
+ if (latestOwner && isProcessAlive(latestOwner.processId)) return false;
49
+ let latestIdentity;
50
+ try { latestIdentity = await lstat(directory); }
51
+ catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return true; throw error; }
52
+ if (latestIdentity.dev !== identity.dev || latestIdentity.ino !== identity.ino) return true;
53
+ try { await rename(directory, stale); }
54
+ catch (error) {
55
+ if (["ENOENT", "ENOTEMPTY", "EEXIST"].includes(String((error as NodeJS.ErrnoException).code))) return true;
56
+ throw error;
57
+ }
58
+ await rm(stale, { recursive: true, force: true });
59
+ return true;
60
+ }
61
+
62
+ export async function acquireDirectoryLock(directory: string, options: { wait: boolean; signal?: AbortSignal; contentionMessage?: string; beforePublish?: () => void | Promise<void> }): Promise<DirectoryLock> {
63
+ const token = randomUUID();
64
+ const owner: LockOwner = { token, processId: process.pid, createdAt: new Date().toISOString() };
65
+ for (;;) {
66
+ const prepared = `${directory}.initializing-${token}`;
67
+ await mkdir(prepared, { mode: 0o700 });
68
+ try {
69
+ await writeJsonAtomic(join(prepared, "owner.json"), owner);
70
+ await options.beforePublish?.();
71
+ try { await rename(prepared, directory); }
72
+ catch (error) {
73
+ if (!["EEXIST", "ENOTEMPTY"].includes(String((error as NodeJS.ErrnoException).code))) throw error;
74
+ await rm(prepared, { recursive: true, force: true });
75
+ if (await reclaimIfStale(directory)) continue;
76
+ if (!options.wait) throw new Error(options.contentionMessage ?? `Lock is already held: ${directory}`);
77
+ await delay(10, options.signal);
78
+ continue;
79
+ }
80
+ return {
81
+ directory,
82
+ token,
83
+ async release() {
84
+ if ((await readOwner(directory))?.token !== token) return;
85
+ const released = `${directory}.released-${token}`;
86
+ try { await rename(directory, released); }
87
+ catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return; throw error; }
88
+ // Delete only the detached inode; a contender may safely publish the next
89
+ // generation at the canonical path immediately after the rename.
90
+ await rm(released, { recursive: true, force: true });
91
+ },
92
+ };
93
+ } catch (error) {
94
+ await rm(prepared, { recursive: true, force: true });
95
+ throw error;
96
+ }
97
+ }
98
+ }
99
+
100
+ /** Only the holder of this lock may start a run's queued tasks. */
101
+ export function queueLockPath(runDirectory: string): string { return join(runDirectory, ".queue.lock"); }
102
+
103
+ /** Replaces a long-lived in-memory copy with the record observed under the lock, including removals. */
104
+ export function adoptRecord<T extends object>(target: T, latest: T): T {
105
+ const mutable = target as unknown as Record<string, unknown>;
106
+ for (const key of Object.keys(mutable)) if (!(key in latest)) delete mutable[key];
107
+ return Object.assign(target, latest);
108
+ }
109
+
110
+ export async function withRunStateLock<T>(runFile: string, signal: AbortSignal | undefined, mutate: (run: RunRecord) => T | Promise<T>): Promise<T> {
111
+ const lock = await acquireDirectoryLock(join(dirname(runFile), ".state.lock"), { wait: true, signal });
112
+ try {
113
+ const run = await readRunRecord(runFile);
114
+ const result = await mutate(run);
115
+ await writeJsonAtomic(runFile, run);
116
+ return result;
117
+ } finally {
118
+ await lock.release();
119
+ }
120
+ }
@@ -2,7 +2,7 @@ import { existsSync, readdirSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { loadQueuedRuns } from "./queued-runs.ts";
4
4
  import { isSettledRunStatus, readReport, readRunRecord, readTaskRecord, type RunStatus, type TaskStatus } from "./records.ts";
5
- import { findRunDirectory } from "./run-locator.ts";
5
+ import { findRunDirectory, resolveRunIdReference } from "./run-locator.ts";
6
6
 
7
7
  export interface RunStatusTask {
8
8
  order: number;
@@ -70,9 +70,14 @@ async function loadDurableStatus(cwd: string, runId: string): Promise<RunStatusS
70
70
  }
71
71
 
72
72
  export async function getSubagentRunStatus(cwd: string, runId: string): Promise<RunStatusSnapshot | undefined> {
73
+ const queuedRuns = await loadQueuedRuns(cwd);
73
74
  const durable = await loadDurableStatus(cwd, runId);
74
- if (durable) return durable;
75
- const queued = (await loadQueuedRuns(cwd)).find((candidate) => candidate.runId === runId);
75
+ if (durable) {
76
+ resolveRunIdReference(runId, [durable.runId, ...queuedRuns.map((candidate) => candidate.runId)]);
77
+ return durable;
78
+ }
79
+ const queuedId = resolveRunIdReference(runId, queuedRuns.map((candidate) => candidate.runId));
80
+ const queued = queuedRuns.find((candidate) => candidate.runId === queuedId);
76
81
  if (!queued) return undefined;
77
82
  return {
78
83
  runId: queued.runId,
@@ -1,6 +1,6 @@
1
1
  import type { HerdrAutomation } from "./herdr.ts";
2
- import { readRunRecord, type RunRecord } from "./records.ts";
3
- import { writeJsonAtomic } from "./state.ts";
2
+ import { type RunRecord } from "./records.ts";
3
+ import { adoptRecord, withRunStateLock } from "./run-state.ts";
4
4
 
5
5
  function isMissingTabError(error: unknown): boolean {
6
6
  const detail = error instanceof Error ? `${error.message}\n${(error as { stderr?: unknown }).stderr ?? ""}` : String(error);
@@ -18,17 +18,16 @@ export async function cleanupRunTab(options: {
18
18
  errorPrefix?: string;
19
19
  }): Promise<{ deferred: boolean }> {
20
20
  const mergeLatest = async (update: (run: RunRecord) => void | Promise<void>, requirePending = false, clearPending = true) => {
21
- const latest = await readRunRecord(options.runFile);
22
- if (requirePending && !latest.cleanupPendingTabIds?.includes(options.tabId)) return;
23
- if (clearPending) {
24
- latest.cleanupPendingTabIds = latest.cleanupPendingTabIds?.filter((tabId) => tabId !== options.tabId);
25
- if (!latest.cleanupPendingTabIds?.length) delete latest.cleanupPendingTabIds;
26
- }
27
- await update(latest);
28
- await writeJsonAtomic(options.runFile, latest);
29
- const mutableRecord = options.runRecord as unknown as Record<string, unknown>;
30
- for (const key of Object.keys(mutableRecord)) if (!(key in latest)) delete mutableRecord[key];
31
- Object.assign(options.runRecord, latest);
21
+ const latest = await withRunStateLock(options.runFile, options.signal, async (run) => {
22
+ if (requirePending && !run.cleanupPendingTabIds?.includes(options.tabId)) return run;
23
+ if (clearPending) {
24
+ run.cleanupPendingTabIds = run.cleanupPendingTabIds?.filter((tabId) => tabId !== options.tabId);
25
+ if (!run.cleanupPendingTabIds?.length) delete run.cleanupPendingTabIds;
26
+ }
27
+ await update(run);
28
+ return run;
29
+ });
30
+ adoptRecord(options.runRecord, latest);
32
31
  };
33
32
  const reportFailure = async (error: unknown, requirePending = false) => {
34
33
  const detail = error instanceof Error ? error.message : String(error);
@@ -51,9 +50,10 @@ export async function cleanupRunTab(options: {
51
50
  return { deferred: false };
52
51
  }
53
52
  if (await options.herdr.isTabFocused(options.tabId, options.signal)) {
54
- const latest = await readRunRecord(options.runFile);
55
- latest.cleanupPendingTabIds = [...new Set([...(latest.cleanupPendingTabIds ?? []), options.tabId])];
56
- await writeJsonAtomic(options.runFile, latest);
53
+ const latest = await withRunStateLock(options.runFile, options.signal, (run) => {
54
+ run.cleanupPendingTabIds = [...new Set([...(run.cleanupPendingTabIds ?? []), options.tabId])];
55
+ return run;
56
+ });
57
57
  Object.assign(options.runRecord, latest);
58
58
  void options.herdr.waitForTabUnfocused(options.tabId, options.signal)
59
59
  .then(() => options.herdr.closeTab(options.tabId, options.signal))
@@ -1,6 +1,5 @@
1
1
  import { CliHerdrAutomation, type HerdrAutomation } from "./herdr.ts";
2
2
  import { loadSubagentConfiguration, type ConfigurationPaths, type SubagentConfiguration } from "./config.ts";
3
- import { runHerdrSubagents, type HerdrSubagentsResult } from "./runner.ts";
4
3
  import { runHerdrSubagentsBatch, type BatchResult, type BatchTask } from "./batch-runner.ts";
5
4
  import { respondToBlockedTask } from "./blocking.ts";
6
5
  import { cleanSubagentRun, historyText, listSubagentHistory, resumeHistoricalTask } from "./history.ts";
@@ -31,9 +30,7 @@ export interface HerdrSubagentsControlToolParams {
31
30
  export interface HerdrSubagentsToolParams {
32
31
  action?: "list" | "status" | "respond" | "history" | "resume" | "cancel" | "cleanup";
33
32
  prompt?: string;
34
- agent?: string;
35
33
  label?: string;
36
- model?: string;
37
34
  tasks?: HerdrSubagentsTaskParams[];
38
35
  concurrency?: number;
39
36
  background?: boolean;
@@ -57,13 +54,6 @@ export interface HerdrSubagentsToolDependencies {
57
54
  cancelRun?: (runId: string) => Promise<{ cancelled: boolean; queued?: boolean; cleanupErrors?: string[] }>;
58
55
  }
59
56
 
60
- export type HerdrSubagentsDetails = HerdrSubagentsResult & {
61
- agent: string;
62
- description: string;
63
- prompt: string;
64
- personaPath: string;
65
- };
66
-
67
57
  export type HerdrSubagentsBatchDetails = BatchResult & { prompts: string[] };
68
58
 
69
59
  type ToolResponse = {
@@ -91,14 +81,6 @@ export function listText(discovery: SubagentConfiguration): string {
91
81
  return lines.join("\n");
92
82
  }
93
83
 
94
- function summaryText(result: HerdrSubagentsResult): string {
95
- if (result.ok) return result.output;
96
- const lines = [result.summary];
97
- if (result.errorMessage) lines.push(`Error: ${result.errorMessage}`);
98
- if (result.recordDirectory) lines.push(`Task record: ${result.recordDirectory}`);
99
- return lines.join("\n");
100
- }
101
-
102
84
  interface ToolExecutionContext {
103
85
  params: HerdrSubagentsToolParams;
104
86
  signal?: AbortSignal;
@@ -131,17 +113,20 @@ const actionHandlers = {
131
113
  },
132
114
  async cleanup({ params, ctx }: ToolExecutionContext) {
133
115
  try {
134
- const cleaned = await cleanSubagentRun(ctx.cwd, params.runId as string);
135
- const text = cleaned ? `Removed Subagent run ${params.runId}.` : `Unknown Subagent run '${params.runId}'.`;
136
- return { content: [{ type: "text", text }], details: { action: "cleanup", runId: params.runId, cleaned }, ...(cleaned ? {} : { isError: true }) };
116
+ const requestedRunId = params.runId as string;
117
+ const runId = (await getSubagentRunStatus(ctx.cwd, requestedRunId))?.runId ?? requestedRunId;
118
+ const cleaned = await cleanSubagentRun(ctx.cwd, runId);
119
+ const text = cleaned ? `Removed Subagent run ${runId}.` : `Unknown Subagent run '${requestedRunId}'.`;
120
+ return { content: [{ type: "text", text }], details: { action: "cleanup", runId, cleaned }, ...(cleaned ? {} : { isError: true }) };
137
121
  } catch (error) {
138
122
  const text = error instanceof Error ? error.message : String(error);
139
123
  return errorResponse(text, { action: "cleanup", runId: params.runId, cleaned: false });
140
124
  }
141
125
  },
142
126
  async cancel({ params, ctx, dependencies }: ToolExecutionContext) {
143
- const runId = (params.runId as string).trim();
127
+ const requestedRunId = (params.runId as string).trim();
144
128
  try {
129
+ const runId = (await getSubagentRunStatus(ctx.cwd, requestedRunId))?.runId ?? requestedRunId;
145
130
  let result: { cancelled: boolean; queued?: boolean; cleanupErrors?: string[] };
146
131
  if (dependencies.cancelRun) result = await dependencies.cancelRun(runId);
147
132
  else {
@@ -159,7 +144,7 @@ const actionHandlers = {
159
144
  const text = result.cancelled ? `Cancelled Subagent run ${runId}.${suffix}` : `Subagent run '${runId}' is not active or queued.`;
160
145
  return { content: [{ type: "text", text }], details: { action: "cancel", runId, ...result }, ...(result.cancelled ? {} : { isError: true }) };
161
146
  } catch (error) {
162
- return errorResponse(error instanceof Error ? error.message : String(error), { action: "cancel", runId, cancelled: false });
147
+ return errorResponse(error instanceof Error ? error.message : String(error), { action: "cancel", runId: requestedRunId, cancelled: false });
163
148
  }
164
149
  },
165
150
  async resume({ params, signal, onUpdate, ctx, dependencies, discovery }: ToolExecutionContext) {
@@ -176,14 +161,15 @@ const actionHandlers = {
176
161
  async respond({ params, signal, onUpdate, ctx, dependencies }: ToolExecutionContext) {
177
162
  try {
178
163
  const result = await respondToBlockedTask(ctx.cwd, (params.runId as string).trim(), (params.answer as string).trim(), {
179
- herdr: dependencies.herdr, taskNumber: params.task, signal, owner: dependencies.owner,
164
+ herdr: dependencies.herdr, taskNumber: params.task as number, signal, owner: dependencies.owner,
180
165
  onUpdate: (partial) => onUpdate?.({ content: [{ type: "text", text: partial.summary }], details: partial }),
181
166
  onStalled: (text) => onUpdate?.({ content: [{ type: "text", text }], details: { action: "respond", runId: params.runId, stalled: true } }),
182
167
  onWarning: (text) => onUpdate?.({ content: [{ type: "text", text }], details: { action: "respond", runId: params.runId, warning: text } }),
183
168
  onCleanupError: (text) => onUpdate?.({ content: [{ type: "text", text }], details: { action: "respond", runId: params.runId, cleanupError: text } }),
184
169
  });
185
- const text = [result.summary, ...(result.question ? [`Question: ${result.question}`] : []), ...result.documents.map((document) => `${document.description}: ${document.path}`)].join("\n");
186
- return { content: [{ type: "text", text }], details: result, ...(result.status === "failed" || result.status === "partial" || result.status === "cancelled" ? { isError: true } : {}) };
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 } : {}) };
187
173
  } catch (error) { return errorResponse(error instanceof Error ? error.message : String(error)); }
188
174
  },
189
175
  async list({ discovery }: ToolExecutionContext) {
@@ -215,9 +201,10 @@ async function executeBatch({ params, signal, onUpdate, ctx, dependencies, disco
215
201
  onUpdate: (partial) => onUpdate?.({ content: [{ type: "text", text: partial.summary }], details: { ...partial, prompts: tasks.map((task) => task.prompt) } }),
216
202
  });
217
203
  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.`);
218
205
  if (result.documents.length) lines.push("", "Documents:", ...result.documents.map((document) => `- ${document.description}: ${document.path}`));
219
206
  const isError = result.status === "failed" || result.status === "partial" || result.status === "cancelled";
220
- return { content: [{ type: "text", text: lines.join("\n") }], details: { ...result, prompts: tasks.map((task) => task.prompt) }, ...(isError ? { isError: true } : {}) };
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 } : {}) };
221
208
  } catch (error) {
222
209
  const text = error instanceof Error ? error.message : String(error);
223
210
  const diagnostic = error as { recordDirectory?: string; runId?: string };
@@ -225,37 +212,6 @@ async function executeBatch({ params, signal, onUpdate, ctx, dependencies, disco
225
212
  }
226
213
  }
227
214
 
228
- async function executeSingle({ params, signal, onUpdate, ctx, dependencies, discovery }: ToolExecutionContext): Promise<ToolResponse> {
229
- const prompt = params.prompt as string;
230
- const selectedName = params.agent?.trim() || "worker";
231
- const persona = discovery.personas.find((agent) => agent.name === selectedName);
232
- if (!persona) return errorResponse(`Unknown persona '${selectedName}'.\n\n${listText(discovery)}`);
233
- const explicitModel = params.model?.trim();
234
- const selectedModel = explicitModel || persona.model || modelId(ctx);
235
- const selectedThinking = persona.thinking ?? (!explicitModel && persona.modelSource === "parent session" ? ctx.thinkingLevel : undefined);
236
- const details = (result: HerdrSubagentsResult): HerdrSubagentsDetails => ({ ...result, agent: persona.name, description: persona.description, prompt, personaPath: persona.filePath });
237
- const result = await runHerdrSubagents(prompt, {
238
- agent: persona.name,
239
- label: params.label,
240
- ...(selectedModel ? { model: selectedModel } : {}),
241
- ...(selectedThinking ? { thinking: selectedThinking } : {}),
242
- ...(persona.tools ? { tools: persona.tools } : {}),
243
- skills: persona.skills,
244
- access: persona.access,
245
- owner: dependencies.owner,
246
- systemPrompt: persona.systemPrompt,
247
- cwd: ctx.cwd,
248
- signal,
249
- herdr: dependencies.herdr,
250
- stalledWarningSeconds: discovery.settings.stalledWarningSeconds,
251
- onStalled: (text) => onUpdate?.({ content: [{ type: "text", text }], details: { status: "running", stalled: true } }),
252
- onCleanupError: (text) => onUpdate?.({ content: [{ type: "text", text }], details: { status: "warning", cleanupError: text } }),
253
- onUpdate: (partial) => onUpdate?.({ content: [{ type: "text", text: partial.summary }], details: details(partial) }),
254
- });
255
- const isError = result.status === "failed" || result.status === "cancelled";
256
- return { content: [{ type: "text", text: summaryText(result) }], details: details(result), ...(isError ? { isError: true } : {}) };
257
- }
258
-
259
215
  export async function executeHerdrSubagents(
260
216
  params: HerdrSubagentsRunParams | HerdrSubagentsToolParams,
261
217
  signal: AbortSignal | undefined,
@@ -264,10 +220,6 @@ export async function executeHerdrSubagents(
264
220
  dependencies: HerdrSubagentsToolDependencies,
265
221
  ): Promise<ToolResponse> {
266
222
  if (params.action) return executeHerdrSubagentsControl(params as HerdrSubagentsControlToolParams, signal, onUpdate, ctx, dependencies);
267
- if (params.prompt && !params.tasks) {
268
- const discovery = loadSubagentConfiguration(ctx.cwd, dependencies.agentsDirectory, modelId(ctx), dependencies.configurationPaths);
269
- return executeSingle({ params, signal, onUpdate, ctx, dependencies, discovery });
270
- }
271
223
  const runParams = params as HerdrSubagentsRunParams;
272
224
  const validationError = validateRunParams(runParams);
273
225
  if (validationError) return { content: [{ type: "text", text: validationError }], details: { errorMessage: validationError }, isError: true };
@@ -21,7 +21,7 @@ export function validateControlParams(params: HerdrSubagentsControlToolParams):
21
21
  const extra = Object.keys(params).find((key) => !allowed.includes(key as keyof HerdrSubagentsControlToolParams));
22
22
  if (extra) return `Field '${extra}' is not valid with action '${params.action}'.`;
23
23
  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())) return "runId and answer are required for action 'respond'.";
24
+ if (params.action === "respond" && (!params.runId?.trim() || !params.answer?.trim() || !params.task)) return "runId, answer, and task are required for action 'respond'.";
25
25
  if (params.action === "resume" && (!params.runId?.trim() || !params.task || !params.prompt?.trim())) return "runId, task, and prompt are required for action 'resume'.";
26
26
  return undefined;
27
27
  }
@@ -240,6 +240,81 @@ async function verifyForegroundFailureHandoff() {
240
240
  await successPromise;
241
241
 
242
242
  const backgroundGate = deferred<void>();
243
+ const queuedCancellationRoot = join(cwd, "queued-cancellation");
244
+ mkdirSync(queuedCancellationRoot, { recursive: true });
245
+ const activeGate = deferred<void>();
246
+ let queuedCancellationCalls = 0;
247
+ const queuedCancellation = harness(async (_params, _signal, _onUpdate, _ctx, dependencies) => {
248
+ queuedCancellationCalls += 1;
249
+ await activeGate.promise;
250
+ return { content: [{ type: "text", text: "done" }], details: { runId: dependencies.runId, label: "active", status: "completed", summary: "done", documents: [], tasks: [], activity: [], effectiveConcurrency: 1, requestedConcurrency: 1 } };
251
+ });
252
+ await startHarness(queuedCancellation, queuedCancellationRoot);
253
+ const activeBackground = await queuedCancellation.tool?.execute("active", { background: true, tasks: [{ name: "active", prompt: "wait" }] }, undefined, undefined, { cwd: queuedCancellationRoot, ui: queuedCancellation.ui });
254
+ await waitUntil(() => queuedCancellationCalls === 1, "active background run did not start");
255
+ const queuedCaller = new AbortController();
256
+ const queuedForeground = queuedCancellation.tool?.execute("queued", { tasks: [{ name: "queued", prompt: "later" }] }, queuedCaller.signal, undefined, { cwd: queuedCancellationRoot, ui: queuedCancellation.ui });
257
+ await new Promise<void>((resolve) => setImmediate(resolve));
258
+ queuedCaller.abort(new Error("foreground tool call cancelled"));
259
+ const queuedCancellationError = await Promise.race([
260
+ queuedForeground.then(() => "resolved", (error: Error) => error.message),
261
+ new Promise<string>((_, reject) => setTimeout(() => reject(new Error("queued foreground call ignored cancellation")), 100)),
262
+ ]);
263
+ if (queuedCancellationError !== "foreground tool call cancelled" || queuedCancellationCalls !== 1) throw new Error("extension did not remove a cancelled foreground run from the dispatcher queue");
264
+ const activeNotification = queuedCancellation.waitForMessage((message) => message.details?.runId === activeBackground.details.runId);
265
+ activeGate.resolve();
266
+ await activeNotification;
267
+
268
+ const blockedOwnerRoot = join(cwd, "blocked-owner");
269
+ mkdirSync(blockedOwnerRoot, { recursive: true });
270
+ let blockedOwnerCalls = 0;
271
+ let blockedOwnerRunId = "";
272
+ let blockedSuccessorStarted = false;
273
+ const becomeRetained = deferred<void>();
274
+ const blockedOwner = harness(async (params, _signal, _onUpdate, _ctx, dependencies) => {
275
+ if (params.action === "respond") {
276
+ return { content: [{ type: "text", text: "answered" }], details: { runId: blockedOwnerRunId, label: "blocked owner", status: "completed", summary: "answered", documents: [], tasks: [], activity: [], effectiveConcurrency: 1, requestedConcurrency: 1 } };
277
+ }
278
+ blockedOwnerCalls += 1;
279
+ if (blockedOwnerCalls > 1) {
280
+ blockedSuccessorStarted = true;
281
+ return { content: [{ type: "text", text: "successor done" }], details: { runId: dependencies.runId, label: "successor", status: "completed", summary: "done", documents: [], tasks: [], activity: [], effectiveConcurrency: 1, requestedConcurrency: 1 } };
282
+ }
283
+ blockedOwnerRunId = dependencies.runId;
284
+ if (blockedOwnerCalls === 1) await becomeRetained.promise;
285
+ return {
286
+ content: [{ type: "text", text: "waiting for input" }],
287
+ details: {
288
+ runId: dependencies.runId, label: "blocked owner", status: "blocked", summary: "waiting for input", question: "Continue?", documents: [],
289
+ tasks: [blockedTask], activity: [{ index: 1, name: blockedTask.name, status: "blocked" }], effectiveConcurrency: 1, requestedConcurrency: 1,
290
+ },
291
+ };
292
+ });
293
+ await startHarness(blockedOwner, blockedOwnerRoot);
294
+ const ownerPromise = blockedOwner.tool?.execute("blocked-owner", { tasks: [{ name: blockedTask.name, prompt: "block" }] }, undefined, undefined, { cwd: blockedOwnerRoot, ui: blockedOwner.ui });
295
+ await waitUntil(() => blockedOwnerCalls === 1, "blocked owner did not start");
296
+ const queuedBeforeBlocked = blockedOwner.tool?.execute("queued-before-blocked", { tasks: [{ name: "queued", prompt: "later" }] }, undefined, undefined, { cwd: blockedOwnerRoot, ui: blockedOwner.ui });
297
+ await new Promise<void>((resolve) => setImmediate(resolve));
298
+ becomeRetained.resolve();
299
+ const ownerResult = await ownerPromise;
300
+ const retainedForeground = await Promise.race([
301
+ queuedBeforeBlocked,
302
+ new Promise((_, reject) => setTimeout(() => reject(new Error("foreground run queued before blocked transition remained stuck")), 100)),
303
+ ]);
304
+ if (!retainedForeground.isError || retainedForeground.details?.blockingRunId !== ownerResult.details.runId || blockedOwnerCalls !== 1) throw new Error("blocked transition did not reject an already-queued foreground run");
305
+ const blockedForeground = await Promise.race([
306
+ blockedOwner.tool?.execute("blocked-successor", { tasks: [{ name: "successor", prompt: "later" }] }, undefined, undefined, { cwd: blockedOwnerRoot, ui: blockedOwner.ui }),
307
+ new Promise((_, reject) => setTimeout(() => reject(new Error("foreground run waited behind a blocked dispatcher owner")), 100)),
308
+ ]);
309
+ if (!blockedForeground.isError || blockedForeground.details?.blockingRunId !== ownerResult.details.runId || !blockedForeground.content?.[0]?.text.includes(ownerResult.details.runId)) {
310
+ throw new Error("foreground run blocked by dispatcher omitted the blocking run ID");
311
+ }
312
+ const queuedBehindBlocked = await blockedOwner.tool?.execute("blocked-background", { background: true, tasks: [{ name: "background", prompt: "later" }] }, undefined, undefined, { cwd: blockedOwnerRoot, ui: blockedOwner.ui });
313
+ if (queuedBehindBlocked.details?.status !== "queued" || blockedOwnerCalls !== 1) throw new Error("background run did not remain queued behind the blocked dispatcher owner");
314
+ await blockedOwner.controlTool?.execute("respond-prefix", { action: "respond", runId: ownerResult.details.runId.slice(0, 8), task: 1, answer: "continue" }, undefined, undefined, { cwd: blockedOwnerRoot, ui: blockedOwner.ui });
315
+ await waitUntil(() => blockedSuccessorStarted, "responding through a run ID prefix did not release the blocked dispatcher owner");
316
+ await blockedOwner.handlers.get("session_shutdown")?.({}, { cwd: blockedOwnerRoot, ui: blockedOwner.ui });
317
+
243
318
  const explicitBackground = harness(async (_params, _signal, _onUpdate, _ctx, dependencies) => {
244
319
  await backgroundGate.promise;
245
320
  return { content: [{ type: "text", text: "background done" }], details: { runId: dependencies.runId, label: "background", status: "completed", summary: "done", documents: [], tasks: [], activity: [], effectiveConcurrency: 1, requestedConcurrency: 1 } };
@@ -272,10 +347,10 @@ async function verifyPublicUiWiring() {
272
347
  const responding = harness(async () => ({ content: [{ type: "text", text: "needs input" }], details: blockedDetails }));
273
348
  await startHarness(responding, cwd);
274
349
  if (!responding.tool) throw new Error("herdr_subagents was not registered for UI wiring test");
275
- const renderedBlocked = responding.tool.renderResult({ content: [{ type: "text", text: "needs input" }], details: { agent: "explorer", status: "blocked", ok: false, summary: "Need input.", documents: [] } }, { isPartial: false }, { fg: (_color: string, text: string) => text, bold: (text: string) => text }).render(100).join("\n");
276
- if (!renderedBlocked.includes("blocked") || renderedBlocked.includes("failed")) throw new Error("single-task blocked result did not render its authoritative status");
350
+ const renderedBlocked = responding.tool.renderResult({ content: [{ type: "text", text: "needs input" }], details: { label: "reviews", status: "blocked", effectiveConcurrency: 1, tasks: [{ index: 1, name: "second", status: "blocked", summary: "Need input." }] } }, { isPartial: false }, { fg: (_color: string, text: string) => text, bold: (text: string) => text }).render(100).join("\n");
351
+ if (!renderedBlocked.includes("blocked") || renderedBlocked.includes("failed")) throw new Error("blocked result did not render its authoritative status");
277
352
  if (!responding.controlTool) throw new Error("herdr_subagents_control was not registered for UI wiring test");
278
- await responding.controlTool.execute("call", { action: "respond", runId: "blocked-run", answer: "main" }, undefined, undefined, { cwd, ui: responding.ui });
353
+ await responding.controlTool.execute("call", { action: "respond", runId: "blocked-run", task: 2, answer: "main" }, undefined, undefined, { cwd, ui: responding.ui });
279
354
  if (!responding.renderedLines.some((line) => line.includes("second") && line.includes("blocked"))) throw new Error("blocked response did not refresh Fleet rows");
280
355
  if (!responding.terminalInput?.("\u001b[B")?.consume || !responding.terminalInput?.("\u001b[B")?.consume || !responding.terminalInput?.("\r")?.consume) throw new Error("Fleet terminal input was not consumed while selecting");
281
356
  await new Promise((resolve) => setTimeout(resolve, 0));