@henryqw/pi-subagent 3.0.2 → 3.1.0

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.
@@ -1,55 +1,53 @@
1
- import { spawn } from "node:child_process";
2
- import { existsSync } from "node:fs";
3
- import { basename } from "node:path";
4
- import { StringDecoder } from "node:string_decoder";
5
- import { StringEnum } from "@earendil-works/pi-ai";
6
- import { type AgentSessionEvent, type ExtensionAPI, type ExtensionContext, type Theme } from "@earendil-works/pi-coding-agent";
1
+ import type { Usage } from "@earendil-works/pi-ai";
2
+ import { type ExtensionAPI, type ExtensionContext, type Theme } from "@earendil-works/pi-coding-agent";
7
3
  import { type Component, truncateToWidth, type TUI, visibleWidth } from "@earendil-works/pi-tui";
8
- import { DEFAULT_TIMEOUT_CONFIG, readSubagentConfig, type SubagentTimeoutConfig } from "./config.ts";
9
4
  import {
10
5
  availableTaskModels,
11
- THINKING_LEVELS,
12
6
  type ThinkingLevel,
13
7
  modelReference,
14
- PROFILE_NAMES,
15
8
  resolveAvailableModel,
16
9
  resolveConfiguredTaskRoute,
17
10
  type ResolvedTaskRoute,
18
11
  taskThinkingLevels,
19
12
  } from "@henryqw/pi-task-models";
20
- import { Type } from "typebox";
21
- import { createChildWorktree, createRoleLaunch, finalizeChildWorktree, isProfileName, loadRoles, resolveTaskRoute, worktreeContextNote, type WorktreeInfo, type WorktreePayload } from "@henryqw/pi-subagent";
13
+ import {
14
+ capEphemeralSubagentOutput as capOutput,
15
+ createChildWorktree,
16
+ createEphemeralSubagentExecutor,
17
+ createRoleLaunch,
18
+ EphemeralSubagentError,
19
+ finalizeChildWorktree,
20
+ loadRoles,
21
+ resolveTaskRoute,
22
+ worktreeContextNote,
23
+ type EphemeralSubagentResult,
24
+ type EphemeralSubagentTimeout,
25
+ type Role,
26
+ type WorktreeInfo,
27
+ type WorktreePayload,
28
+ } from "@henryqw/pi-subagent";
29
+ import { DEFAULT_TIMEOUT_CONFIG, readSubagentConfig, type SubagentTimeoutConfig } from "./config.ts";
30
+ import {
31
+ formatBackgroundWorkflowResult,
32
+ formatWorkflowResult,
33
+ formatWorkflowUpdate,
34
+ WorkflowAbortedError,
35
+ WorkflowFailureError,
36
+ type WorkflowTransportEntry,
37
+ } from "./result-transport.ts";
38
+ import {
39
+ identifyWorkflowEntries,
40
+ parseWorkflow,
41
+ runForegroundWorkflow,
42
+ WorkflowSchema,
43
+ type Delegation,
44
+ type ParsedWorkflow,
45
+ type WorkflowEntry,
46
+ } from "./workflow.ts";
47
+
48
+ export { capOutput };
22
49
 
23
50
  const SUBAGENT_TASK = "pi-subagent/delegateTask";
24
- const MAX_OUTPUT_BYTES = 50 * 1024;
25
- const MAX_JSON_EVENT_BYTES = 1024 * 1024;
26
- const PI_JSON_EVENTS = {
27
- agent_start: true,
28
- agent_end: true,
29
- agent_settled: true,
30
- turn_start: true,
31
- turn_end: true,
32
- message_start: true,
33
- message_update: true,
34
- message_end: true,
35
- tool_execution_start: true,
36
- tool_execution_update: true,
37
- tool_execution_end: true,
38
- queue_update: true,
39
- compaction_start: true,
40
- compaction_end: true,
41
- entry_appended: true,
42
- session_info_changed: true,
43
- thinking_level_changed: true,
44
- auto_retry_start: true,
45
- auto_retry_end: true,
46
- summarization_retry_scheduled: true,
47
- summarization_retry_attempt_start: true,
48
- summarization_retry_finished: true,
49
- bash_execution_update: true,
50
- } satisfies Record<AgentSessionEvent["type"], true>;
51
- const CONSUMED_JSON_EVENTS = new Set(["message_start", "message_update", "message_end"]);
52
- const JSON_EVENT_TYPE = /^\s*\{\s*"type"\s*:\s*"([^"\\]+)"/;
53
51
  const WIDGET_KEY = "subagent-status";
54
52
  const WIDGET_INTERVAL_MS = 80;
55
53
  const TERMINAL_DISPLAY_MS = 1_000;
@@ -60,7 +58,7 @@ const DEFAULT_TIMEOUT_POLICY = {
60
58
  };
61
59
  const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
62
60
 
63
- type TimeoutPolicy = typeof DEFAULT_TIMEOUT_POLICY;
61
+ type TimeoutPolicy = EphemeralSubagentTimeout;
64
62
 
65
63
  /** Merge validated config-file timeout fields over defaults; absent keys keep defaults. */
66
64
  export function resolveTimeoutPolicy(partial: SubagentTimeoutConfig | undefined): TimeoutPolicy {
@@ -69,19 +67,6 @@ export function resolveTimeoutPolicy(partial: SubagentTimeoutConfig | undefined)
69
67
  maxMs: partial?.maxMinutes === undefined ? DEFAULT_TIMEOUT_POLICY.maxMs : partial.maxMinutes * 60_000,
70
68
  };
71
69
  }
72
- class SubagentTimeoutError extends Error {}
73
- type ChildResult = {
74
- exitCode: number;
75
- output: string;
76
- stderr: string;
77
- stopReason?: string;
78
- errorMessage?: string;
79
- };
80
- type DelegateResult = {
81
- content: [{ type: "text"; text: string }];
82
- details: Record<string, unknown>;
83
- isError?: boolean;
84
- };
85
70
  type WidgetStatus = "working" | "success" | "failure" | "aborted";
86
71
  type WidgetItem = {
87
72
  roleRoute: string;
@@ -93,80 +78,10 @@ type WidgetItem = {
93
78
  removeAt?: number;
94
79
  };
95
80
 
96
- const cleanText = (value: unknown, field: string, file: string): string => {
97
- if (typeof value !== "string" || !value.trim() || value.includes("\0")) {
98
- throw new Error(`${file}: ${field} must be non-empty text.`);
99
- }
100
- return value.trim();
101
- };
102
-
103
- function piInvocation(args: string[]): { command: string; args: string[] } {
104
- const currentScript = process.argv[1];
105
- const isBunVirtualScript = currentScript?.startsWith("/$bunfs/root/");
106
- if (currentScript && !isBunVirtualScript && existsSync(currentScript)) {
107
- return { command: process.execPath, args: [currentScript, ...args] };
108
- }
109
- const executable = basename(process.execPath).toLowerCase();
110
- if (!/^(node|bun)(\.exe)?$/.test(executable)) return { command: process.execPath, args };
111
- return { command: "pi", args };
112
- }
113
-
114
- function assistantText(message: unknown): string | undefined {
115
- if (!message || typeof message !== "object" || Array.isArray(message)) return;
116
- const record = message as Record<string, unknown>;
117
- if (record.role !== "assistant" || !Array.isArray(record.content)) return;
118
- const text = record.content
119
- .filter((part): part is { type: "text"; text: string } =>
120
- Boolean(part && typeof part === "object" && !Array.isArray(part)
121
- && (part as Record<string, unknown>).type === "text"
122
- && typeof (part as Record<string, unknown>).text === "string"))
123
- .map((part) => part.text)
124
- .join("\n");
125
- return text || undefined;
126
- }
127
-
128
- function utf8Prefix(text: string, maxBytes: number): string {
129
- return new StringDecoder().write(Buffer.from(text).subarray(0, maxBytes));
130
- }
131
-
132
- function cappedPrefix(text: string, totalBytes: number): string {
133
- if (totalBytes <= MAX_OUTPUT_BYTES) return text;
134
- const worstCaseMarker = `\n\n[Output truncated: ${totalBytes} bytes omitted]`;
135
- const prefix = utf8Prefix(text, MAX_OUTPUT_BYTES - Buffer.byteLength(worstCaseMarker, "utf8"));
136
- const omittedBytes = totalBytes - Buffer.byteLength(prefix, "utf8");
137
- return `${prefix}\n\n[Output truncated: ${omittedBytes} bytes omitted]`;
138
- }
139
-
140
- export function capOutput(text: string): string {
141
- return cappedPrefix(text, Buffer.byteLength(text, "utf8"));
142
- }
143
-
144
- type BoundedText = { prefix: string; totalBytes: number };
145
-
146
- function appendBounded(target: BoundedText, text: string): void {
147
- target.totalBytes += Buffer.byteLength(text, "utf8");
148
- const remaining = MAX_OUTPUT_BYTES - Buffer.byteLength(target.prefix, "utf8");
149
- if (remaining > 0) target.prefix += utf8Prefix(text, remaining);
150
- }
151
-
152
- function boundedText(target: BoundedText): string {
153
- return cappedPrefix(target.prefix, target.totalBytes);
154
- }
155
-
156
81
  function taskSummary(task: string): string {
157
82
  return task.replace(/[\u0000-\u001f\u007f-\u009f]/g, " ").trim().split(/\s+/).slice(0, 4).join(" ");
158
83
  }
159
84
 
160
- /** Finalizes an isolated child's worktree; returns its report, or undefined when absent/failed. */
161
- async function finalizeWorktreePayload(worktree: WorktreeInfo | undefined): Promise<WorktreePayload | undefined> {
162
- if (!worktree) return undefined;
163
- try {
164
- return await finalizeChildWorktree(worktree);
165
- } catch {
166
- return undefined;
167
- }
168
- }
169
-
170
85
  function formatTokens(tokens: number): string {
171
86
  if (tokens < 1_000) return String(tokens);
172
87
  if (tokens < 100_000) return `${(tokens / 1_000).toFixed(1)}k`;
@@ -181,12 +96,6 @@ function formatElapsed(startedAt: number, finishedAt = Date.now()): string {
181
96
  return hours ? `${hours}h ${minutes}m` : minutes ? `${minutes}m ${seconds % 60}s` : `${seconds}s`;
182
97
  }
183
98
 
184
- function usageTokens(value: unknown): number | undefined {
185
- if (!value || typeof value !== "object" || Array.isArray(value)) return;
186
- const total = (value as Record<string, unknown>).totalTokens;
187
- return typeof total === "number" && Number.isFinite(total) && total >= 0 ? Math.round(total) : undefined;
188
- }
189
-
190
99
  function statusGlyph(status: WidgetStatus, spinnerIndex: number, theme: Theme): string {
191
100
  switch (status) {
192
101
  case "working": return theme.fg("accent", SPINNER_FRAMES[spinnerIndex % SPINNER_FRAMES.length]!);
@@ -240,270 +149,6 @@ function renderWidgetRows(
240
149
  return lines;
241
150
  }
242
151
 
243
- async function runPi(
244
- args: string[],
245
- cwd: string,
246
- signal: AbortSignal | undefined,
247
- onUpdate: ((text: string) => void) | undefined,
248
- onTokens: ((tokens: number) => void) | undefined,
249
- timeoutPolicy: TimeoutPolicy,
250
- ): Promise<ChildResult> {
251
- if (signal?.aborted) throw new Error("Subagent was aborted.");
252
- return await new Promise<ChildResult>((resolve, reject) => {
253
- const invocation = piInvocation(args);
254
- const child = spawn(invocation.command, invocation.args, {
255
- cwd,
256
- shell: false,
257
- stdio: ["ignore", "pipe", "pipe"],
258
- detached: process.platform !== "win32",
259
- });
260
- child.stdout.setEncoding("utf8");
261
- child.stderr.setEncoding("utf8");
262
- let lineParts: string[] = [];
263
- let lineBytes = 0;
264
- let linePrefix = "";
265
- let lineEventType: string | undefined;
266
- let ignoreLine = false;
267
- let output = "";
268
- const stderr = { prefix: "", totalBytes: 0 };
269
- const partial = { prefix: "", totalBytes: 0 };
270
- let hasPartialText = false;
271
- let stopReason: string | undefined;
272
- let errorMessage: string | undefined;
273
- let spawnError: Error | undefined;
274
- let protocolError: Error | undefined;
275
- let aborted = false;
276
- const startedAt = Date.now();
277
- const maxDeadline = startedAt + timeoutPolicy.maxMs;
278
- let lastEventAt = startedAt;
279
- let deadline = Math.min(startedAt + timeoutPolicy.idleMs, maxDeadline);
280
- let timedOutAfterMs: number | undefined;
281
- let timeoutReason: "idle" | "maximum" | undefined;
282
- let childExited = false;
283
- let completedTokens = 0;
284
- let currentTokens = 0;
285
- let deadlineTimer: ReturnType<typeof setTimeout> | undefined;
286
- let killTimer: ReturnType<typeof setTimeout> | undefined;
287
-
288
- const scheduleDeadline = () => {
289
- if (deadlineTimer) clearTimeout(deadlineTimer);
290
- deadline = Math.min(lastEventAt + timeoutPolicy.idleMs, maxDeadline);
291
- const scheduledDeadline = deadline;
292
- deadlineTimer = setTimeout(
293
- () => timeout(scheduledDeadline - startedAt, scheduledDeadline === maxDeadline ? "maximum" : "idle"),
294
- Math.max(0, scheduledDeadline - Date.now()),
295
- );
296
- deadlineTimer.unref();
297
- };
298
-
299
- const observeEvent = () => {
300
- if (aborted || timedOutAfterMs !== undefined || childExited) return;
301
- const now = Date.now();
302
- if (now >= deadline) {
303
- timeout(deadline - startedAt, deadline === maxDeadline ? "maximum" : "idle");
304
- return;
305
- }
306
- lastEventAt = now;
307
- scheduleDeadline();
308
- };
309
-
310
- const processLine = (line: string) => {
311
- if (!line.trim()) return;
312
- let event: unknown;
313
- try {
314
- event = JSON.parse(line);
315
- } catch {
316
- return;
317
- }
318
- if (!event || typeof event !== "object" || Array.isArray(event)) return;
319
- const record = event as Record<string, unknown>;
320
- if (typeof record.type !== "string" || !Object.hasOwn(PI_JSON_EVENTS, record.type)) return;
321
- observeEvent();
322
- if (record.type === "message_start") {
323
- partial.prefix = "";
324
- partial.totalBytes = 0;
325
- hasPartialText = false;
326
- return;
327
- }
328
- if (record.type === "message_update") {
329
- const tokens = usageTokens(record.usage);
330
- if (tokens !== undefined) {
331
- currentTokens = tokens;
332
- onTokens?.(completedTokens + currentTokens);
333
- }
334
- const update = record.assistantMessageEvent;
335
- if (update && typeof update === "object" && !Array.isArray(update)) {
336
- const assistantEvent = update as Record<string, unknown>;
337
- if (assistantEvent.type === "text_start" && hasPartialText) appendBounded(partial, "\n");
338
- if (assistantEvent.type === "text_start") hasPartialText = true;
339
- if (assistantEvent.type === "text_delta" && typeof assistantEvent.delta === "string") {
340
- hasPartialText = true;
341
- appendBounded(partial, assistantEvent.delta);
342
- output = boundedText(partial);
343
- onUpdate?.(output);
344
- }
345
- }
346
- return;
347
- }
348
- if (record.type !== "message_end") return;
349
- const text = assistantText(record.message);
350
- if (text !== undefined) {
351
- output = capOutput(text);
352
- onUpdate?.(output);
353
- }
354
- if (record.message && typeof record.message === "object" && !Array.isArray(record.message)) {
355
- const message = record.message as Record<string, unknown>;
356
- if (message.role === "assistant") {
357
- completedTokens += usageTokens(message.usage) ?? currentTokens;
358
- currentTokens = 0;
359
- onTokens?.(completedTokens);
360
- }
361
- if (typeof message.stopReason === "string") stopReason = message.stopReason;
362
- if (typeof message.errorMessage === "string") errorMessage = message.errorMessage;
363
- }
364
- };
365
-
366
- const killTree = async (force: boolean): Promise<void> => {
367
- if (!child.pid) return;
368
- if (process.platform === "win32") {
369
- await new Promise<void>((resolve) => {
370
- const taskkill = spawn("taskkill", [...(force ? ["/F"] : []), "/T", "/PID", String(child.pid)], {
371
- stdio: "ignore",
372
- windowsHide: true,
373
- });
374
- taskkill.once("error", () => resolve());
375
- taskkill.once("close", () => resolve());
376
- });
377
- return;
378
- }
379
- try {
380
- process.kill(-child.pid, force ? "SIGKILL" : "SIGTERM");
381
- } catch {
382
- child.kill(force ? "SIGKILL" : "SIGTERM");
383
- }
384
- };
385
-
386
- child.stdout.on("data", (data: string) => {
387
- if (protocolError) return;
388
- let offset = 0;
389
- while (offset < data.length) {
390
- const newline = data.indexOf("\n", offset);
391
- const end = newline === -1 ? data.length : newline;
392
- const part = data.slice(offset, end);
393
- if (!ignoreLine) {
394
- linePrefix += part.slice(0, Math.max(0, 256 - linePrefix.length));
395
- const eventType = JSON_EVENT_TYPE.exec(linePrefix)?.[1];
396
- if (eventType && !lineEventType) lineEventType = eventType;
397
- lineBytes += Buffer.byteLength(part, "utf8");
398
- if (lineBytes > MAX_JSON_EVENT_BYTES) {
399
- if (lineEventType && !CONSUMED_JSON_EVENTS.has(lineEventType)) {
400
- ignoreLine = true;
401
- lineParts = [];
402
- lineBytes = 0;
403
- } else {
404
- protocolError = new Error(`Subagent JSON event exceeds ${MAX_JSON_EVENT_BYTES} bytes.`);
405
- void killTree(true);
406
- return;
407
- }
408
- } else if (part) lineParts.push(part);
409
- }
410
- if (newline === -1) return;
411
- if (!ignoreLine) processLine(lineParts.join(""));
412
- lineParts = [];
413
- lineBytes = 0;
414
- linePrefix = "";
415
- lineEventType = undefined;
416
- ignoreLine = false;
417
- offset = newline + 1;
418
- }
419
- });
420
- child.stderr.on("data", (data: string) => {
421
- appendBounded(stderr, data);
422
- });
423
- child.on("error", (error) => { spawnError = error; });
424
-
425
- const stop = (force = false) => {
426
- if (force) {
427
- void killTree(true);
428
- return;
429
- }
430
- void killTree(false);
431
- killTimer = setTimeout(
432
- () => void killTree(true),
433
- Math.min(5_000, Math.max(0, maxDeadline - Date.now())),
434
- );
435
- killTimer.unref();
436
- };
437
- const abort = () => {
438
- if (timedOutAfterMs !== undefined || childExited) return;
439
- aborted = true;
440
- stop();
441
- };
442
- function timeout(afterMs: number, reason: "idle" | "maximum") {
443
- if (timedOutAfterMs !== undefined || childExited) return;
444
- if (reason === "maximum") {
445
- if (!aborted) {
446
- timedOutAfterMs = afterMs;
447
- timeoutReason = reason;
448
- }
449
- stop(true);
450
- return;
451
- }
452
- if (aborted) return;
453
- timedOutAfterMs = afterMs;
454
- timeoutReason = reason;
455
- stop();
456
- }
457
- scheduleDeadline();
458
- signal?.addEventListener("abort", abort, { once: true });
459
- if (signal?.aborted) abort();
460
-
461
- // `close` waits for stdio EOF, which descendants can hold after Pi exits.
462
- // Kill the process group at Pi's exit boundary so `close` can settle.
463
- child.once("exit", () => {
464
- childExited = true;
465
- if (deadlineTimer) clearTimeout(deadlineTimer);
466
- signal?.removeEventListener("abort", abort);
467
- void killTree(true);
468
- });
469
- child.on("close", async (code) => {
470
- if (!protocolError && lineBytes) processLine(lineParts.join(""));
471
- await killTree(true);
472
- if (deadlineTimer) clearTimeout(deadlineTimer);
473
- if (killTimer) clearTimeout(killTimer);
474
- signal?.removeEventListener("abort", abort);
475
- if (aborted) reject(new Error("Subagent was aborted."));
476
- else if (timedOutAfterMs !== undefined) reject(new SubagentTimeoutError(
477
- timeoutReason === "maximum"
478
- ? `Subagent reached its maximum runtime after ${formatElapsed(0, timedOutAfterMs)}.`
479
- : `Subagent timed out after ${formatElapsed(0, timeoutPolicy.idleMs)} without a recognized Pi event.`,
480
- ));
481
- else if (protocolError) reject(protocolError);
482
- else if (spawnError) reject(spawnError);
483
- else resolve({ exitCode: code ?? 1, output, stderr: boundedText(stderr), stopReason, errorMessage });
484
- });
485
- });
486
- }
487
-
488
- const Parameters = Type.Object({
489
- role: Type.String({ description: "Configured Subagent role name" }),
490
- task: Type.String({
491
- description: "Bounded task packet: objective; exact scope and exclusions; relevant context and constraints; expected deliverable; validation. Never the whole parent request.",
492
- }),
493
- model: Type.Optional(Type.String({
494
- description: "Designated model as provider/modelId; overrides modelClass. Unknown references reject with the list of available models.",
495
- })),
496
- modelClass: Type.Optional(StringEnum(PROFILE_NAMES, {
497
- description: "Classify task complexity: fast for narrow lookups or mechanical edits; balanced for normal bounded work; frontier for ambiguous, cross-cutting, or high-risk reasoning; fav for the user's favorite model when they ask for it. Defaults to the shared pi-subagent/delegateTask assignment.",
498
- })),
499
- background: Type.Optional(Type.Boolean({
500
- description: "Run without blocking: returns a task ID immediately and delivers the outcome as a message when the Subagent settles; the result cannot be waited on. Set only when the user explicitly asks for non-blocking delegation. Prefer blocking delegation whenever the parent needs the result to continue.",
501
- })),
502
- thinking: Type.Optional(StringEnum(THINKING_LEVELS, {
503
- description: "Override the resolved route's thinking level (e.g. when the user asks for deeper or lighter reasoning). Must be supported by the resolved model.",
504
- })),
505
- });
506
-
507
152
  function resolveDesignatedRoute(ctx: ExtensionContext, reference: string, thinking?: ThinkingLevel): ResolvedTaskRoute {
508
153
  const models = availableTaskModels(ctx);
509
154
  const model = resolveAvailableModel(models, reference, ctx.model?.provider);
@@ -511,7 +156,7 @@ function resolveDesignatedRoute(ctx: ExtensionContext, reference: string, thinki
511
156
  throw new Error(`Unknown delegate_task model: ${reference}. Available models: ${models.map((candidate) => modelReference(candidate)).join(", ") || "none"}.`);
512
157
  }
513
158
  const levels = taskThinkingLevels(ctx, model);
514
- if (thinking !== undefined) {
159
+ if (thinking !== undefined) {
515
160
  if (!levels.includes(thinking)) {
516
161
  throw new Error(`delegate_task thinking ${thinking} is not usable for ${modelReference(model)} in this session. Usable levels here: ${levels.join(", ") || "none"}.`);
517
162
  }
@@ -525,6 +170,20 @@ function resolveDesignatedRoute(ctx: ExtensionContext, reference: string, thinki
525
170
 
526
171
  const BACKGROUND_RESULT_TYPE = "subagent-background-result";
527
172
 
173
+ function boundedError(error: unknown): Error {
174
+ const message = capOutput(error instanceof Error ? error.message : String(error));
175
+ return error instanceof Error && error.message === message ? error : new Error(message, { cause: error });
176
+ }
177
+
178
+ function failedToolPatch(error: WorkflowFailureError | WorkflowAbortedError) {
179
+ return {
180
+ content: [{ type: "text" as const, text: error.message }],
181
+ details: error.details,
182
+ isError: true as const,
183
+ ...(error.usage === undefined ? {} : { usage: error.usage }),
184
+ };
185
+ }
186
+
528
187
  const roleSummary = (): string => {
529
188
  try {
530
189
  const roles = loadRoles();
@@ -551,11 +210,11 @@ export default function subagentExtension(
551
210
  // Reject "2workers", "1.5", "1e3" — parseInt would silently accept prefixes —
552
211
  // and digit strings that overflow to Infinity, which would disable the cap.
553
212
  if (!/^\d+$/.test(maxSubagentsRaw) || !/^[1-9]\d*$/.test(maxSubagentsRaw)) {
554
- throw new Error(`PI_SUBAGENT_MAX_SUBAGENTS must be a positive integer, got ${JSON.stringify(maxSubagentsRaw)}.`);
213
+ throw boundedError(new Error(`PI_SUBAGENT_MAX_SUBAGENTS must be a positive integer, got ${JSON.stringify(maxSubagentsRaw)}.`));
555
214
  }
556
215
  const parsed = Number.parseInt(maxSubagentsRaw, 10);
557
216
  if (!Number.isSafeInteger(parsed)) {
558
- throw new Error(`PI_SUBAGENT_MAX_SUBAGENTS exceeds the supported range, got ${JSON.stringify(maxSubagentsRaw)}.`);
217
+ throw boundedError(new Error(`PI_SUBAGENT_MAX_SUBAGENTS exceeds the supported range, got ${JSON.stringify(maxSubagentsRaw)}.`));
559
218
  }
560
219
  maxActiveSubagents = parsed;
561
220
  }
@@ -563,44 +222,17 @@ export default function subagentExtension(
563
222
  // Explicit policy argument (tests/embedders) wins; otherwise resolve from
564
223
  // config file over defaults.
565
224
  const timeoutPolicy: TimeoutPolicy = overrideTimeoutPolicy ?? resolveTimeoutPolicy(loadedConfig.config.timeout);
225
+ const executor = createEphemeralSubagentExecutor({ maxConcurrency: maxActiveSubagents, timeout: timeoutPolicy });
566
226
  // Background children outlive the launching tool call, so they get their own
567
227
  // abort signal: tied to the session, not to the turn that started them.
568
228
  const backgroundTasks = new Map<string, { controller: AbortController; settled: Promise<void> }>();
229
+ const failedToolPatches = new Map<string, ReturnType<typeof failedToolPatch>>();
569
230
  // Latest known session context; refreshed on session lifecycle and model
570
231
  // changes so queued background launches resolve against effective state.
571
232
  let latestCtx: ExtensionContext | undefined;
572
233
  // Bumped by session_start and session_shutdown; background tasks may only
573
234
  // deliver into the exact session that launched them.
574
235
  let sessionEpoch = 0;
575
- let activeChildren = 0;
576
- const queuedChildren: Array<() => void> = [];
577
- const acquireChildPermit = (signal: AbortSignal | undefined): Promise<void> => {
578
- if (signal?.aborted) return Promise.reject(new Error("Subagent was aborted."));
579
- if (activeChildren < maxActiveSubagents) {
580
- activeChildren++;
581
- return Promise.resolve();
582
- }
583
- return new Promise<void>((resolve, reject) => {
584
- function abort() {
585
- const index = queuedChildren.indexOf(grant);
586
- if (index < 0) return;
587
- queuedChildren.splice(index, 1);
588
- signal?.removeEventListener("abort", abort);
589
- reject(new Error("Subagent was aborted."));
590
- }
591
- const grant = () => {
592
- signal?.removeEventListener("abort", abort);
593
- resolve();
594
- };
595
- queuedChildren.push(grant);
596
- signal?.addEventListener("abort", abort, { once: true });
597
- });
598
- };
599
- const releaseChildPermit = () => {
600
- const grant = queuedChildren.shift();
601
- if (grant) grant();
602
- else activeChildren--;
603
- };
604
236
  let widgetInstalled = false;
605
237
  let widgetTimer: ReturnType<typeof setInterval> | undefined;
606
238
  let spinnerIndex = 0;
@@ -684,6 +316,7 @@ export default function subagentExtension(
684
316
  for (const warning of startupWarnings.splice(0)) ctx.ui.notify(warning, "warning");
685
317
  });
686
318
  pi.on("session_shutdown", async (_event, ctx) => {
319
+ failedToolPatches.clear();
687
320
  stopWidgetTimer();
688
321
  widgetItems.clear();
689
322
  activeTui = undefined;
@@ -705,75 +338,130 @@ export default function subagentExtension(
705
338
  pi.on("agent_settled", (_event, ctx) => {
706
339
  latestCtx = ctx;
707
340
  });
341
+ pi.on("tool_result", (event) => {
342
+ if (event.toolName !== "delegate_task") return;
343
+ const patch = failedToolPatches.get(event.toolCallId);
344
+ if (!patch) return;
345
+ failedToolPatches.delete(event.toolCallId);
346
+ return patch;
347
+ });
708
348
 
709
- const reportBackground = async (
349
+ const reportBackground = (
710
350
  launchEpoch: number,
711
351
  taskId: string,
712
- details: { role: string; model?: string; thinkingLevel?: string },
713
- outcome: "completed" | "failed" | "aborted",
714
- text: string,
715
- worktreePayload?: WorktreePayload,
716
- setupRecovery?: string,
717
- ): Promise<void> => {
352
+ mode: ParsedWorkflow["mode"],
353
+ entries: readonly WorkflowTransportEntry[],
354
+ setupRecoveries: ReadonlyMap<string, string>,
355
+ ): void => {
718
356
  const stale = launchEpoch !== sessionEpoch;
719
- if (stale && (!worktreePayload || worktreePayload.pruned) && !setupRecovery) return;
720
- // Custom messages convert to user-role LLM messages, so the parent agent
721
- // sees the outcome on its next turn without a forced turn now.
357
+ const retained = entries.filter(({ worktreePayload }) => worktreePayload && !worktreePayload.pruned);
358
+ if (stale && !retained.length && !setupRecoveries.size) return;
359
+ const transport = formatBackgroundWorkflowResult(mode, entries);
360
+ const outcome = stale ? "aborted" : transport.failed ? "failed" : "completed";
361
+ const content = stale
362
+ ? capOutput([
363
+ "Background workflow left recoverable isolated work after session shutdown.",
364
+ `Task ID: ${taskId}`,
365
+ `Mode: ${mode}`,
366
+ "Recovery locations:",
367
+ ...retained.map((entry) =>
368
+ `- [${entry.index}] worktree path=${JSON.stringify(entry.worktreePayload!.path)} branch=${JSON.stringify(entry.worktreePayload!.branch)}`),
369
+ ...[...setupRecoveries].map(([id, recovery]) => {
370
+ const entry = entries.find((candidate) => candidate.id === id)!;
371
+ return `- [${entry.index}] setup state: ${recovery}`;
372
+ }),
373
+ "Evidence:",
374
+ ...retained.map((entry) => {
375
+ const payload = entry.worktreePayload!;
376
+ return `- [${entry.index}] retained worktree commits=${payload.commits} dirty=${payload.dirty} inspection_failed=${payload.inspection_failed === true}`;
377
+ }),
378
+ ...[...setupRecoveries].map(([id, recovery]) => {
379
+ const entry = entries.find((candidate) => candidate.id === id)!;
380
+ return `- [${entry.index}] recoverable WorktreeSetupError: ${recovery}`;
381
+ }),
382
+ ].join("\n"))
383
+ : transport.text;
722
384
  try {
385
+ // Custom messages convert to user-role LLM messages, so the parent agent
386
+ // sees the aggregate on its next turn without forcing one now.
723
387
  pi.sendMessage({
724
388
  customType: BACKGROUND_RESULT_TYPE,
725
- content: stale
726
- ? worktreePayload && !worktreePayload.pruned
727
- ? `Background subagent ${taskId} (${details.role}) left recoverable isolated work after session shutdown.\n${JSON.stringify(worktreePayload)}`
728
- : `Background subagent ${taskId} (${details.role}) left recoverable isolated setup state after session shutdown.\n${setupRecovery}`
729
- : `Background subagent ${taskId} (${details.role}) ${outcome}.\n\n${capOutput(text)}${worktreePayload ? `\n${JSON.stringify(worktreePayload)}` : ""}`,
389
+ content,
730
390
  display: true,
731
- details: { ...details, taskId, outcome, ...(stale ? { recovery: true } : {}) },
391
+ details: {
392
+ ...transport.details,
393
+ taskId,
394
+ outcome,
395
+ ...(transport.usage === undefined ? {} : { usage: transport.usage }),
396
+ ...(stale ? { recovery: true } : {}),
397
+ },
732
398
  }, { triggerTurn: false });
733
- } catch {
734
- // Session may already be gone; the widget row still shows the outcome.
399
+ } catch (error) {
400
+ // Delivery can disappear during teardown; only an active UI gets a visible failure.
401
+ if (!stale && latestCtx?.hasUI) {
402
+ latestCtx.ui.notify(boundedError(new Error(
403
+ `Background workflow ${taskId} result delivery failed: ${error instanceof Error ? error.message : String(error)}`,
404
+ )).message, "error");
405
+ }
735
406
  }
736
407
  };
737
408
 
738
409
  pi.registerTool({
739
410
  name: "delegate_task",
740
411
  label: "Subagent",
741
- description: `Delegate one bounded, independently executable task to one isolated Pi Subagent. Roles: ${roleSummary()}.`,
742
- promptSnippet: "Delegate one bounded, independently executable task to an isolated role",
412
+ description: `Delegate one selected single, parallel, or chain workflow of bounded tasks to isolated Pi Subagents. Roles: ${roleSummary()}.`,
413
+ promptSnippet: "Delegate one bounded single, parallel, or chain workflow to isolated roles",
743
414
  promptGuidelines: [
744
- "Before calling delegate_task, split broad work into the smallest independent bounded tasks; keep integration and cross-cutting decisions in Main.",
745
- "Each delegate_task task must state its objective, exact scope and exclusions, relevant context and constraints, expected deliverable, and validation; never pass the parent request unchanged.",
746
- "Use fav when the user explicitly asks for their favorite model. Otherwise, choose the least capable modelClass that can reliably complete the task: fast for narrow work, balanced for normal work, and frontier only for ambiguous, cross-cutting, or high-risk work.",
747
- "Submit independent delegate_task calls together for parallel execution. Parallel edits must own non-overlapping files; otherwise sequence them. Use the minimum number of Subagents needed.",
748
- "Use background: true only when the user explicitly asks for non-blocking delegation (for example \"keep working while this runs\"); the result arrives as a message after the current turn and the parent must not assume it is available yet.",
415
+ "Call delegate_task with exactly one mode: role+task for one task, tasks for 1–8 independent parallel tasks, or chain for 1–8 dependent sequential tasks using {previous} for the immediately preceding assistant output.",
416
+ "Every delegate_task entry must state its objective, exact scope and exclusions, relevant context and constraints, expected deliverable, and validation; never pass the parent request unchanged.",
417
+ "For each delegate_task entry, use fav only when the user asks for their favorite model; otherwise choose fast for narrow work, balanced for normal work, and frontier only for ambiguous, cross-cutting, or high-risk work.",
418
+ "Parallel delegate_task entries must own non-overlapping files. Keep integration and cross-cutting decisions in Main, and use the minimum number of Subagents needed.",
419
+ "delegate_task background applies to the whole selected workflow and returns before results exist; use it only when the user explicitly asks for non-blocking work.",
749
420
  ],
750
- parameters: Parameters,
421
+ parameters: WorkflowSchema,
422
+ prepareArguments(args) {
423
+ try {
424
+ const workflow = parseWorkflow(args);
425
+ if (workflow.mode === "single") return { ...workflow.delegations[0], background: workflow.background };
426
+ if (workflow.mode === "parallel") return { tasks: workflow.delegations, background: workflow.background };
427
+ return { chain: workflow.delegations, background: workflow.background };
428
+ } catch (error) {
429
+ throw boundedError(error);
430
+ }
431
+ },
751
432
  async execute(toolCallId, params, signal, onUpdate, ctx) {
752
- const task = cleanText(params.task, "task", "delegate_task");
753
- const roles = loadRoles();
754
- const role = roles.find((candidate) => candidate.name === params.role);
755
- if (!role) {
756
- throw new Error(`Unknown Subagent role: ${params.role}. Available roles: ${roles.map(({ name }) => name).join(", ") || "none"}.`);
433
+ const throwIfAborted = () => {
434
+ if (signal?.aborted) throw new EphemeralSubagentError("aborted", "Subagent was aborted.", signal.reason);
435
+ };
436
+ throwIfAborted();
437
+ let workflow: ParsedWorkflow;
438
+ let roles: Role[];
439
+ try {
440
+ workflow = parseWorkflow(params);
441
+ roles = loadRoles();
442
+ const knownRoles = new Set(roles.map(({ name }) => name));
443
+ for (const { role } of workflow.delegations) {
444
+ if (!knownRoles.has(role)) {
445
+ throw new Error(`Unknown Subagent role: ${role}. Available roles: ${roles.map(({ name }) => name).join(", ") || "none"}.`);
446
+ }
447
+ }
448
+ } catch (error) {
449
+ throw boundedError(error);
757
450
  }
451
+ throwIfAborted();
452
+ const rolesByName = new Map(roles.map((role) => [role.name, role]));
758
453
 
759
- if (params.modelClass !== undefined && !isProfileName(params.modelClass)) {
760
- throw new Error("delegate_task modelClass must be fast, balanced, frontier, or fav.");
761
- }
762
- // Resolve against the latest known session context: a task queued past
763
- // the cap must pick up model or Codex account changes that happened
764
- // while it waited.
454
+ // Resolve against the latest known session context after each FIFO permit.
765
455
  const launchCtx = () => latestCtx ?? ctx;
766
- // The explicit thinking override participates in route resolution itself:
767
- // routes that cannot honor it are skipped so fallback routes get considered.
768
- const resolveLaunch = () => createRoleLaunch(pi, launchCtx(), {
456
+ const resolveLaunch = (role: Role, delegation: Delegation) => createRoleLaunch(pi, launchCtx(), {
769
457
  role,
770
- route: params.model !== undefined
771
- ? resolveDesignatedRoute(launchCtx(), cleanText(params.model, "model", "delegate_task"), params.thinking)
772
- : params.modelClass === undefined
773
- ? resolveConfiguredTaskRoute(launchCtx(), SUBAGENT_TASK, undefined, params.thinking)
774
- : resolveTaskRoute(launchCtx(), params.modelClass, undefined, params.thinking),
458
+ route: delegation.model !== undefined
459
+ ? resolveDesignatedRoute(launchCtx(), delegation.model, delegation.thinking)
460
+ : delegation.modelClass === undefined
461
+ ? resolveConfiguredTaskRoute(launchCtx(), SUBAGENT_TASK, undefined, delegation.thinking)
462
+ : resolveTaskRoute(launchCtx(), delegation.modelClass, undefined, delegation.thinking),
775
463
  });
776
- const notifyMissingSkills = (launch: ReturnType<typeof resolveLaunch>) => {
464
+ const notifyMissingSkills = (role: Role, launch: ReturnType<typeof resolveLaunch>) => {
777
465
  if (launch.missingSkills.length) {
778
466
  ctx.ui.notify(
779
467
  `Subagent role ${role.name} skipped unavailable Pi skills: ${launch.missingSkills.join(", ")}.`,
@@ -782,128 +470,233 @@ export default function subagentExtension(
782
470
  }
783
471
  };
784
472
 
785
- if (params.background) {
473
+ const foregroundWorkflow: ParsedWorkflow = { ...workflow, background: false };
474
+ const entries = identifyWorkflowEntries(toolCallId, foregroundWorkflow);
475
+ const states = new Map<string, WorkflowTransportEntry>(entries.map((entry) => [entry.id, {
476
+ id: entry.id,
477
+ index: entry.index,
478
+ role: entry.delegation.role,
479
+ status: "pending",
480
+ }]));
481
+ const setupRecoveries = new Map<string, string>();
482
+ const emitUpdate = (enabled: boolean) => {
483
+ if (!enabled) return;
484
+ const update = formatWorkflowUpdate(workflow.mode, [...states.values()]);
485
+ onUpdate?.({
486
+ content: [{ type: "text", text: update.text }],
487
+ details: update.details,
488
+ ...(update.usage === undefined ? {} : { usage: update.usage }),
489
+ });
490
+ };
491
+ const runWorkflow = async (workflowSignal: AbortSignal | undefined, emitToolUpdates: boolean) => {
492
+ try {
493
+ return await runForegroundWorkflow<EphemeralSubagentResult>(toolCallId, foregroundWorkflow, async (entry: WorkflowEntry) => {
494
+ const role = rolesByName.get(entry.delegation.role)!;
495
+ let model: string | undefined;
496
+ let thinkingLevel: string | undefined;
497
+ let worktree: WorktreeInfo | undefined;
498
+ let worktreePayload: WorktreePayload | undefined;
499
+ let child: EphemeralSubagentResult | undefined;
500
+ let rejected: unknown;
501
+ let rejectedUsage: Usage | undefined;
502
+ let aborted = false;
503
+ let status: "succeeded" | "failed" | "rejected" = "rejected";
504
+ let text = "Subagent did not start.";
505
+ const setState = (
506
+ nextStatus: "running" | "succeeded" | "failed" | "rejected",
507
+ nextText: string,
508
+ ) => {
509
+ const usage = child?.usage ?? rejectedUsage;
510
+ const base = {
511
+ id: entry.id,
512
+ index: entry.index,
513
+ role: role.name,
514
+ ...(model === undefined ? {} : { model }),
515
+ ...(thinkingLevel === undefined ? {} : { thinkingLevel }),
516
+ ...(worktreePayload === undefined ? {} : { worktreePayload }),
517
+ ...(usage === undefined ? {} : { usage }),
518
+ };
519
+ states.set(entry.id, nextStatus === "failed" || nextStatus === "rejected"
520
+ ? { ...base, status: nextStatus, failure: nextText }
521
+ : { ...base, status: nextStatus, assistantOutput: nextText });
522
+ };
523
+ try {
524
+ child = await executor.run({
525
+ signal: workflowSignal,
526
+ onUpdate: (output) => {
527
+ setState("running", output);
528
+ emitUpdate(emitToolUpdates);
529
+ },
530
+ onTokens: (tokens) => updateWidgetTokens(entry.id, tokens),
531
+ prepare: async () => {
532
+ // Route and effective Role resources resolve only after this entry's
533
+ // shared executor permit, before isolated state is created.
534
+ const launch = resolveLaunch(role, entry.delegation);
535
+ notifyMissingSkills(role, launch);
536
+ model = modelReference(launch.model);
537
+ thinkingLevel = launch.thinkingLevel;
538
+ if (role.isolation === "worktree") {
539
+ worktree = await createChildWorktree(ctx.cwd, entry.id, undefined, workflowSignal);
540
+ }
541
+ startWidgetItem(entry.id, role.name, launch.model.id, launch.thinkingLevel, entry.delegation.task, ctx);
542
+ setState("running", "");
543
+ emitUpdate(emitToolUpdates);
544
+ return {
545
+ launch,
546
+ task: worktree ? `${entry.delegation.task}${worktreeContextNote(worktree)}` : entry.delegation.task,
547
+ cwd: worktree?.cwd ?? ctx.cwd,
548
+ };
549
+ },
550
+ });
551
+ if (child.outcome === "failure") {
552
+ status = "failed";
553
+ text = capOutput(child.errorMessage || child.stderr.trim() || child.output || `Subagent exited with code ${child.exitCode}.`);
554
+ } else {
555
+ status = "succeeded";
556
+ text = child.output;
557
+ }
558
+ } catch (error) {
559
+ rejected = error;
560
+ aborted = error instanceof EphemeralSubagentError && error.code === "aborted";
561
+ rejectedUsage = error instanceof EphemeralSubagentError
562
+ ? (error as EphemeralSubagentError & { usage?: Usage }).usage
563
+ : undefined;
564
+ const cause = error instanceof EphemeralSubagentError ? error.cause : error;
565
+ if (cause instanceof Error && cause.name === "WorktreeSetupError") {
566
+ setupRecoveries.set(entry.id, cause.message);
567
+ }
568
+ text = capOutput(error instanceof Error ? error.message : String(error));
569
+ }
570
+ try {
571
+ worktreePayload = worktree ? await finalizeChildWorktree(worktree) : undefined;
572
+ } catch (error) {
573
+ rejected = error;
574
+ aborted = false;
575
+ status = "rejected";
576
+ text = capOutput(error instanceof Error ? error.message : String(error));
577
+ worktreePayload = worktree ? {
578
+ path: worktree.path,
579
+ branch: worktree.branch,
580
+ commits: 0,
581
+ dirty: false,
582
+ pruned: false,
583
+ inspection_failed: true,
584
+ note: capOutput(`Worktree finalization failed (${text}); commits/dirty UNKNOWN. Inspect retained work before assuming no changes.`),
585
+ } : undefined;
586
+ }
587
+ if (worktreePayload?.inspection_failed) {
588
+ const note = capOutput(worktreePayload.note ?? `Worktree inspection failed; inspect ${worktreePayload.path} before assuming no work.`);
589
+ worktreePayload = { ...worktreePayload, note };
590
+ rejected = new Error(note, rejected === undefined ? undefined : { cause: rejected });
591
+ status = "rejected";
592
+ text = capOutput(`${note}\n${text}`);
593
+ }
594
+ if (rejected !== undefined) status = "rejected";
595
+ setState(status, text);
596
+ try {
597
+ finishWidgetItem(entry.id, aborted ? "aborted" : status === "succeeded" ? "success" : "failure");
598
+ emitUpdate(emitToolUpdates);
599
+ } catch (error) {
600
+ rejected = error;
601
+ status = "rejected";
602
+ setState("rejected", capOutput(error instanceof Error ? error.message : String(error)));
603
+ finishWidgetItem(entry.id, "failure");
604
+ }
605
+ if (rejected !== undefined) throw rejected;
606
+ return child!.outcome === "success"
607
+ ? { ok: true, assistantOutput: text, result: child! }
608
+ : { ok: false, result: child! };
609
+ }, workflowSignal);
610
+ } finally {
611
+ if (workflow.mode === "chain" && (workflowSignal?.aborted
612
+ || [...states.values()].some(({ status }) => status === "failed" || status === "rejected"))) {
613
+ for (const [id, state] of states) {
614
+ if (state.status === "pending") states.set(id, { ...state, status: "skipped" });
615
+ }
616
+ }
617
+ }
618
+ };
619
+
620
+ const recordInfrastructureFailure = (error: unknown) => {
621
+ if ([...states.values()].some(({ status }) => status === "failed" || status === "rejected")) return;
622
+ const target = [...states.values()].find(({ status }) => status === "pending" || status === "running")
623
+ ?? [...states.values()].at(-1)!;
624
+ states.set(target.id, {
625
+ id: target.id,
626
+ index: target.index,
627
+ role: target.role,
628
+ ...(target.model === undefined ? {} : { model: target.model }),
629
+ ...(target.thinkingLevel === undefined ? {} : { thinkingLevel: target.thinkingLevel }),
630
+ ...(target.worktreePayload === undefined ? {} : { worktreePayload: target.worktreePayload }),
631
+ ...(target.usage === undefined ? {} : { usage: target.usage }),
632
+ status: "rejected",
633
+ failure: capOutput(error instanceof Error ? error.message : String(error)),
634
+ });
635
+ };
636
+
637
+ throwIfAborted();
638
+ if (workflow.background) {
786
639
  const taskId = `bg-${++backgroundSequence}-${Date.now().toString(36)}`;
787
640
  const controller = new AbortController();
788
641
  // Freeze the launching session now: a task that settles after a
789
642
  // reload must not deliver into whichever session is active then.
790
643
  const launchEpoch = sessionEpoch;
791
644
  const settled = (async () => {
792
- let acquired = false;
793
- let widgetStatus: Exclude<WidgetStatus, "working"> = "failure";
794
- // Role is known up front; model/thinking join after launch resolution.
795
- let details: { role: string; model?: string; thinkingLevel?: string } = { role: role.name };
796
- let worktree: WorktreeInfo | undefined;
797
645
  try {
798
- await acquireChildPermit(controller.signal);
799
- acquired = true;
800
- // Same contract as foreground: isolation only after the permit,
801
- // setup failure fails closed via the catch below.
802
- if (role.isolation === "worktree") worktree = await createChildWorktree(ctx.cwd, toolCallId, undefined, controller.signal);
803
- // Resolve resources only once launched: a task queued past the
804
- // cap must not start with model routes or skills resolved before
805
- // registries or accounts changed while it waited.
806
- const launch = resolveLaunch();
807
- notifyMissingSkills(launch);
808
- details = { role: role.name, model: modelReference(launch.model), thinkingLevel: launch.thinkingLevel };
809
- startWidgetItem(taskId, role.name, launch.model.id, launch.thinkingLevel, task, ctx);
810
- const result = await runPi(
811
- ["--mode", "json", "-p", ...launch.args, `Task: ${worktree ? `${task}${worktreeContextNote(worktree)}` : task}`],
812
- worktree?.cwd ?? ctx.cwd,
813
- controller.signal,
814
- undefined,
815
- (tokens) => updateWidgetTokens(taskId, tokens),
816
- timeoutPolicy,
817
- );
818
- const failed = result.exitCode !== 0 || result.stopReason === "error" || result.stopReason === "aborted";
819
- widgetStatus = result.stopReason === "aborted" ? "aborted" : failed ? "failure" : "success";
820
- const text = failed
821
- ? result.errorMessage || result.stderr.trim() || result.output || `Subagent exited with code ${result.exitCode}.`
822
- : result.output || "(no output)";
823
- const payloadLine = await finalizeWorktreePayload(worktree);
824
- await reportBackground(
825
- launchEpoch,
826
- taskId,
827
- details,
828
- result.stopReason === "aborted" ? "aborted" : failed ? "failed" : "completed",
829
- text,
830
- payloadLine,
831
- );
832
- } catch (error) {
833
- const aborted = controller.signal.aborted && !(error instanceof SubagentTimeoutError);
834
- widgetStatus = aborted ? "aborted" : "failure";
835
- const failureText = error instanceof Error ? error.message : String(error);
836
- const payloadLine = await finalizeWorktreePayload(worktree);
837
- await reportBackground(
838
- launchEpoch,
839
- taskId,
840
- details,
841
- aborted ? "aborted" : "failed",
842
- failureText,
843
- payloadLine,
844
- error instanceof Error && error.name === "WorktreeSetupError" ? failureText : undefined,
845
- );
646
+ // Let the acknowledgement resolve before any route, Skill, worktree,
647
+ // permit, or child work starts.
648
+ await new Promise<void>((resolve) => setImmediate(resolve));
649
+ try {
650
+ await runWorkflow(controller.signal, false);
651
+ } catch (error) {
652
+ if (!controller.signal.aborted) recordInfrastructureFailure(error);
653
+ }
654
+ reportBackground(launchEpoch, taskId, workflow.mode, [...states.values()], setupRecoveries);
846
655
  } finally {
847
- if (acquired) releaseChildPermit();
848
- finishWidgetItem(taskId, widgetStatus);
849
656
  backgroundTasks.delete(taskId);
850
657
  }
851
658
  })();
852
659
  backgroundTasks.set(taskId, { controller, settled });
853
660
  void settled;
661
+ const acknowledgement = capOutput([
662
+ `Background workflow ${taskId} accepted.`,
663
+ `Mode: ${workflow.mode}`,
664
+ "Entries:",
665
+ ...entries.map((entry) =>
666
+ `- [${entry.index}] id=${JSON.stringify(entry.id)} role=${JSON.stringify(entry.delegation.role)}`),
667
+ "The aggregate outcome arrives as one message; keep working or end your turn.",
668
+ ].join("\n"));
854
669
  return {
855
- content: [{ type: "text" as const, text: `Background subagent ${taskId} started (${role.name}). The outcome arrives as a message when the task settles; keep working or end your turn.` }],
856
- details: { role: role.name, taskId, background: true },
670
+ content: [{ type: "text" as const, text: acknowledgement }],
671
+ details: {
672
+ taskId,
673
+ background: true,
674
+ mode: workflow.mode,
675
+ entries: entries.map((entry) => ({ id: entry.id, index: entry.index, role: entry.delegation.role })),
676
+ },
857
677
  };
858
678
  }
859
679
 
860
- const launch = resolveLaunch();
861
- notifyMissingSkills(launch);
862
- const modelReferenceValue = modelReference(launch.model);
863
- const details = { role: role.name, model: modelReferenceValue, thinkingLevel: launch.thinkingLevel };
864
- await acquireChildPermit(signal);
865
- let widgetStatus: Exclude<WidgetStatus, "working"> = "failure";
866
- let result: DelegateResult | undefined;
867
- let worktree: WorktreeInfo | undefined;
868
- let rethrow: unknown;
869
- let worktreePayload: WorktreePayload | undefined;
680
+ let outcomes: Awaited<ReturnType<typeof runWorkflow>>;
870
681
  try {
871
- // Isolation is created only after preflight and permit acquisition so a
872
- // rejected delegation cannot leak worktrees; setup failure fails closed.
873
- if (role.isolation === "worktree") worktree = await createChildWorktree(ctx.cwd, toolCallId, undefined, signal);
874
- startWidgetItem(toolCallId, role.name, launch.model.id, launch.thinkingLevel, task, ctx);
875
- const child = await runPi(
876
- ["--mode", "json", "-p", ...launch.args, `Task: ${worktree ? `${task}${worktreeContextNote(worktree)}` : task}`],
877
- worktree?.cwd ?? ctx.cwd,
878
- signal,
879
- (text) => onUpdate?.({ content: [{ type: "text", text }], details }),
880
- (tokens) => updateWidgetTokens(toolCallId, tokens),
881
- timeoutPolicy,
882
- );
883
- const failed = child.exitCode !== 0 || child.stopReason === "error" || child.stopReason === "aborted";
884
- widgetStatus = child.stopReason === "aborted" ? "aborted" : failed ? "failure" : "success";
885
- const text = capOutput(failed
886
- ? child.errorMessage || child.stderr.trim() || child.output || `Subagent exited with code ${child.exitCode}.`
887
- : child.output || "(no output)");
888
- result = { content: [{ type: "text" as const, text }], details, ...(failed ? { isError: true } : {}) };
889
- return result;
682
+ outcomes = await runWorkflow(signal, true);
890
683
  } catch (error) {
891
- if (signal?.aborted && !(error instanceof SubagentTimeoutError)) widgetStatus = "aborted";
892
- rethrow = error;
893
- } finally {
894
- releaseChildPermit();
895
- finishWidgetItem(toolCallId, widgetStatus);
896
- worktreePayload = await finalizeWorktreePayload(worktree);
897
- if (result && worktreePayload) result.content[0].text += `\n${JSON.stringify(worktreePayload)}`;
684
+ if (!signal?.aborted) throw error;
685
+ const aborted = new WorkflowAbortedError(workflow.mode, [...states.values()], signal.reason);
686
+ failedToolPatches.set(toolCallId, failedToolPatch(aborted));
687
+ throw aborted;
898
688
  }
899
- if (rethrow !== undefined) {
900
- // A kept-but-unreported worktree is unrecoverable: attach the report
901
- // locating it (path/branch/dirty state) to whatever failure escapes.
902
- throw worktreePayload
903
- ? new Error(`${rethrow instanceof Error ? rethrow.message : String(rethrow)}\n${JSON.stringify(worktreePayload)}`)
904
- : rethrow;
689
+ if (outcomes.some(({ status }) => status === "failed" || status === "rejected")) {
690
+ const failure = new WorkflowFailureError(workflow.mode, [...states.values()]);
691
+ failedToolPatches.set(toolCallId, failedToolPatch(failure));
692
+ throw failure;
905
693
  }
906
- return result!;
694
+ const result = formatWorkflowResult(workflow.mode, [...states.values()]);
695
+ return {
696
+ content: [{ type: "text" as const, text: result.text }],
697
+ details: result.details,
698
+ ...(result.usage === undefined ? {} : { usage: result.usage }),
699
+ };
907
700
  },
908
701
  });
909
702
  }