@maxiaochao/pi-toolkit 0.10.21 → 0.10.23

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 (31) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/package.json +1 -1
  3. package/packages/herdr-subagents/CHANGELOG.md +22 -0
  4. package/packages/herdr-subagents/README.md +16 -8
  5. package/packages/herdr-subagents/RESUME-LIFECYCLE-DESIGN.md +84 -0
  6. package/packages/herdr-subagents/TESTING.md +13 -3
  7. package/packages/herdr-subagents/extensions/herdr-subagents.ts +4 -17
  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 +56 -15
  11. package/packages/herdr-subagents/src/blocking.ts +115 -72
  12. package/packages/herdr-subagents/src/continue-run.ts +22 -7
  13. package/packages/herdr-subagents/src/fleet.ts +2 -1
  14. package/packages/herdr-subagents/src/herdr.ts +10 -1
  15. package/packages/herdr-subagents/src/historical-resume.ts +184 -0
  16. package/packages/herdr-subagents/src/history.ts +5 -131
  17. package/packages/herdr-subagents/src/live-agent.ts +99 -35
  18. package/packages/herdr-subagents/src/parameters.ts +2 -2
  19. package/packages/herdr-subagents/src/protocol.ts +3 -1
  20. package/packages/herdr-subagents/src/reconcile.ts +156 -135
  21. package/packages/herdr-subagents/src/records.ts +50 -32
  22. package/packages/herdr-subagents/src/resume-state.ts +46 -0
  23. package/packages/herdr-subagents/src/run-pane-allocator.ts +39 -37
  24. package/packages/herdr-subagents/src/run-state.ts +120 -0
  25. package/packages/herdr-subagents/src/status.ts +0 -6
  26. package/packages/herdr-subagents/src/tab-cleanup.ts +16 -16
  27. package/packages/herdr-subagents/src/tool.ts +1 -54
  28. package/packages/herdr-subagents/src/validation.ts +1 -1
  29. package/packages/herdr-subagents/tests/extension-contract.ts +4 -4
  30. package/packages/herdr-subagents/tests/run-tests.mjs +579 -60
  31. package/packages/herdr-subagents/src/runner.ts +0 -234
@@ -1,5 +1,5 @@
1
1
  import type { HerdrAutomation } from "./herdr.ts";
2
- import { writeJsonAtomic } from "./state.ts";
2
+ import { withRunStateLock } from "./run-state.ts";
3
3
  import type { RunRecord } from "./records.ts";
4
4
  import { applyRunStatus } from "./run-status.ts";
5
5
  import { cleanupRunTab } from "./tab-cleanup.ts";
@@ -33,45 +33,47 @@ export class RunPaneAllocator {
33
33
  this.allocation = this.allocation.then(async () => {
34
34
  const { herdr, run, runFile, signal } = this.options;
35
35
  const env = childEnvironment(taskDirectory);
36
- let anchorExists = Boolean(run.tabId && this.lastPaneId);
37
- if (anchorExists && this.options.validateExistingPane) {
38
- const survivingPaneIds: string[] = [];
39
- for (const existingPaneId of this.paneIds) {
40
- if (await herdr.paneExists(existingPaneId, signal)) survivingPaneIds.push(existingPaneId);
36
+ const allocated = await withRunStateLock(runFile, signal, async (latest) => {
37
+ const currentPaneIds = [...(latest.paneIds ?? [])];
38
+ let lastPaneId = currentPaneIds.at(-1);
39
+ let anchorExists = Boolean(latest.tabId && lastPaneId);
40
+ if (anchorExists && this.options.validateExistingPane) {
41
+ const surviving: string[] = [];
42
+ for (const existingPaneId of currentPaneIds) if (await herdr.paneExists(existingPaneId, signal)) surviving.push(existingPaneId);
43
+ currentPaneIds.splice(0, currentPaneIds.length, ...surviving);
44
+ lastPaneId = currentPaneIds.at(-1);
45
+ anchorExists = Boolean(lastPaneId);
41
46
  }
42
- this.paneIds.splice(0, this.paneIds.length, ...survivingPaneIds);
43
- this.lastPaneId = this.paneIds.at(-1);
44
- anchorExists = Boolean(this.lastPaneId);
45
- }
46
- if (!anchorExists) {
47
- const staleTabId = run.tabId;
48
- const tab = await herdr.createTab({ workspaceId: process.env.HERDR_WORKSPACE_ID ?? "", cwd: run.cwd, label: `SA · ${run.label}`, env, focus: false, signal });
49
- run.tabId = tab.tabId;
50
- paneId = tab.paneId;
51
- this.paneIds.splice(0);
52
- if (staleTabId && staleTabId !== tab.tabId) {
53
- run.staleTabIds = [...new Set([...(run.staleTabIds ?? []), staleTabId])];
54
- await writeJsonAtomic(runFile, run);
55
- await cleanupRunTab({
56
- herdr, tabId: staleTabId, runFile, runRecord: run, signal, onError: this.options.onCleanupError,
57
- errorPrefix: `Could not close stale Herdr tab ${staleTabId}`,
58
- onClosed: (latest) => {
59
- latest.staleTabIds = latest.staleTabIds?.filter((id) => id !== staleTabId);
60
- if (!latest.staleTabIds?.length) delete latest.staleTabIds;
61
- },
62
- }).catch((error) => {
63
- if (!this.options.onCleanupError) throw error;
64
- });
47
+ let staleTabId: string | undefined;
48
+ if (!anchorExists) {
49
+ staleTabId = latest.tabId;
50
+ const tab = await herdr.createTab({ workspaceId: process.env.HERDR_WORKSPACE_ID ?? "", cwd: latest.cwd, label: `SA · ${latest.label}`, env, focus: false, signal });
51
+ latest.tabId = tab.tabId;
52
+ paneId = tab.paneId;
53
+ currentPaneIds.splice(0);
54
+ if (staleTabId && staleTabId !== tab.tabId) latest.staleTabIds = [...new Set([...(latest.staleTabIds ?? []), staleTabId])];
55
+ } else {
56
+ paneId = (await herdr.splitPane({ paneId: lastPaneId as string, cwd: latest.cwd, direction: currentPaneIds.length % 2 ? "right" : "down", focus: false, env, signal })).paneId;
65
57
  }
66
- } else {
67
- const split = await herdr.splitPane({ paneId: this.lastPaneId as string, cwd: run.cwd, direction: this.paneIds.length % 2 ? "right" : "down", focus: false, env, signal });
68
- paneId = split.paneId;
58
+ currentPaneIds.push(paneId);
59
+ applyRunStatus(latest, "running");
60
+ latest.paneIds = currentPaneIds;
61
+ return { latest, staleTabId: staleTabId && staleTabId !== latest.tabId ? staleTabId : undefined };
62
+ });
63
+ this.paneIds.splice(0, this.paneIds.length, ...(allocated.latest.paneIds ?? []));
64
+ this.lastPaneId = this.paneIds.at(-1);
65
+ Object.assign(run, allocated.latest);
66
+ if (allocated.staleTabId) {
67
+ const staleTabId = allocated.staleTabId;
68
+ await cleanupRunTab({
69
+ herdr, tabId: staleTabId, runFile, runRecord: run, signal, onError: this.options.onCleanupError,
70
+ errorPrefix: `Could not close stale Herdr tab ${staleTabId}`,
71
+ onClosed: (latest) => {
72
+ latest.staleTabIds = latest.staleTabIds?.filter((id) => id !== staleTabId);
73
+ if (!latest.staleTabIds?.length) delete latest.staleTabIds;
74
+ },
75
+ }).catch((error) => { if (!this.options.onCleanupError) throw error; });
69
76
  }
70
- this.lastPaneId = paneId;
71
- this.paneIds.push(paneId);
72
- applyRunStatus(run, "running");
73
- run.paneIds = [...this.paneIds];
74
- await writeJsonAtomic(runFile, run);
75
77
  });
76
78
  await this.allocation;
77
79
  return paneId;
@@ -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
+ }
@@ -12,8 +12,6 @@ export interface RunStatusTask {
12
12
  summary?: string;
13
13
  question?: string;
14
14
  error?: string;
15
- failureKind?: "missing_input";
16
- requiredInput?: string;
17
15
  }
18
16
 
19
17
  export interface RunStatusSnapshot {
@@ -46,8 +44,6 @@ async function loadDurableStatus(cwd: string, runId: string): Promise<RunStatusS
46
44
  const report = reportIsCurrent && existsSync(reportFile) ? await readReport(reportFile) : undefined;
47
45
  const question = report?.question ?? task.question;
48
46
  const error = report?.error ?? task.error;
49
- const failureKind = report?.failureKind ?? task.failureKind;
50
- const requiredInput = report?.requiredInput ?? task.requiredInput;
51
47
  tasks.push({
52
48
  order: task.order,
53
49
  name: task.name ?? task.agent ?? `Task ${task.order}`,
@@ -56,8 +52,6 @@ async function loadDurableStatus(cwd: string, runId: string): Promise<RunStatusS
56
52
  ...(report?.summary ? { summary: report.summary } : {}),
57
53
  ...(question ? { question } : {}),
58
54
  ...(error ? { error } : {}),
59
- ...(failureKind ? { failureKind } : {}),
60
- ...(requiredInput ? { requiredInput } : {}),
61
55
  });
62
56
  }
63
57
  return {
@@ -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;
@@ -179,7 +161,7 @@ const actionHandlers = {
179
161
  async respond({ params, signal, onUpdate, ctx, dependencies }: ToolExecutionContext) {
180
162
  try {
181
163
  const result = await respondToBlockedTask(ctx.cwd, (params.runId as string).trim(), (params.answer as string).trim(), {
182
- herdr: dependencies.herdr, taskNumber: params.task, signal, owner: dependencies.owner,
164
+ herdr: dependencies.herdr, taskNumber: params.task as number, signal, owner: dependencies.owner,
183
165
  onUpdate: (partial) => onUpdate?.({ content: [{ type: "text", text: partial.summary }], details: partial }),
184
166
  onStalled: (text) => onUpdate?.({ content: [{ type: "text", text }], details: { action: "respond", runId: params.runId, stalled: true } }),
185
167
  onWarning: (text) => onUpdate?.({ content: [{ type: "text", text }], details: { action: "respond", runId: params.runId, warning: text } }),
@@ -230,37 +212,6 @@ async function executeBatch({ params, signal, onUpdate, ctx, dependencies, disco
230
212
  }
231
213
  }
232
214
 
233
- async function executeSingle({ params, signal, onUpdate, ctx, dependencies, discovery }: ToolExecutionContext): Promise<ToolResponse> {
234
- const prompt = params.prompt as string;
235
- const selectedName = params.agent?.trim() || "worker";
236
- const persona = discovery.personas.find((agent) => agent.name === selectedName);
237
- if (!persona) return errorResponse(`Unknown persona '${selectedName}'.\n\n${listText(discovery)}`);
238
- const explicitModel = params.model?.trim();
239
- const selectedModel = explicitModel || persona.model || modelId(ctx);
240
- const selectedThinking = persona.thinking ?? (!explicitModel && persona.modelSource === "parent session" ? ctx.thinkingLevel : undefined);
241
- const details = (result: HerdrSubagentsResult): HerdrSubagentsDetails => ({ ...result, agent: persona.name, description: persona.description, prompt, personaPath: persona.filePath });
242
- const result = await runHerdrSubagents(prompt, {
243
- agent: persona.name,
244
- label: params.label,
245
- ...(selectedModel ? { model: selectedModel } : {}),
246
- ...(selectedThinking ? { thinking: selectedThinking } : {}),
247
- ...(persona.tools ? { tools: persona.tools } : {}),
248
- skills: persona.skills,
249
- access: persona.access,
250
- owner: dependencies.owner,
251
- systemPrompt: persona.systemPrompt,
252
- cwd: ctx.cwd,
253
- signal,
254
- herdr: dependencies.herdr,
255
- stalledWarningSeconds: discovery.settings.stalledWarningSeconds,
256
- onStalled: (text) => onUpdate?.({ content: [{ type: "text", text }], details: { status: "running", stalled: true } }),
257
- onCleanupError: (text) => onUpdate?.({ content: [{ type: "text", text }], details: { status: "warning", cleanupError: text } }),
258
- onUpdate: (partial) => onUpdate?.({ content: [{ type: "text", text: partial.summary }], details: details(partial) }),
259
- });
260
- const isError = result.status === "failed" || result.status === "cancelled";
261
- return { content: [{ type: "text", text: summaryText(result) }], details: details(result), ...(isError ? { isError: true } : {}) };
262
- }
263
-
264
215
  export async function executeHerdrSubagents(
265
216
  params: HerdrSubagentsRunParams | HerdrSubagentsToolParams,
266
217
  signal: AbortSignal | undefined,
@@ -269,10 +220,6 @@ export async function executeHerdrSubagents(
269
220
  dependencies: HerdrSubagentsToolDependencies,
270
221
  ): Promise<ToolResponse> {
271
222
  if (params.action) return executeHerdrSubagentsControl(params as HerdrSubagentsControlToolParams, signal, onUpdate, ctx, dependencies);
272
- if (params.prompt && !params.tasks) {
273
- const discovery = loadSubagentConfiguration(ctx.cwd, dependencies.agentsDirectory, modelId(ctx), dependencies.configurationPaths);
274
- return executeSingle({ params, signal, onUpdate, ctx, dependencies, discovery });
275
- }
276
223
  const runParams = params as HerdrSubagentsRunParams;
277
224
  const validationError = validateRunParams(runParams);
278
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
  }
@@ -311,7 +311,7 @@ async function verifyForegroundFailureHandoff() {
311
311
  }
312
312
  const queuedBehindBlocked = await blockedOwner.tool?.execute("blocked-background", { background: true, tasks: [{ name: "background", prompt: "later" }] }, undefined, undefined, { cwd: blockedOwnerRoot, ui: blockedOwner.ui });
313
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), answer: "continue" }, undefined, undefined, { cwd: blockedOwnerRoot, ui: blockedOwner.ui });
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
315
  await waitUntil(() => blockedSuccessorStarted, "responding through a run ID prefix did not release the blocked dispatcher owner");
316
316
  await blockedOwner.handlers.get("session_shutdown")?.({}, { cwd: blockedOwnerRoot, ui: blockedOwner.ui });
317
317
 
@@ -347,10 +347,10 @@ async function verifyPublicUiWiring() {
347
347
  const responding = harness(async () => ({ content: [{ type: "text", text: "needs input" }], details: blockedDetails }));
348
348
  await startHarness(responding, cwd);
349
349
  if (!responding.tool) throw new Error("herdr_subagents was not registered for UI wiring test");
350
- 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");
351
- 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");
352
352
  if (!responding.controlTool) throw new Error("herdr_subagents_control was not registered for UI wiring test");
353
- 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 });
354
354
  if (!responding.renderedLines.some((line) => line.includes("second") && line.includes("blocked"))) throw new Error("blocked response did not refresh Fleet rows");
355
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");
356
356
  await new Promise((resolve) => setTimeout(resolve, 0));