@henryqw/pi-subagent 3.0.3 → 4.0.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,52 @@
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
+ formatDuration,
21
+ loadRoles,
22
+ resolveTaskRoute,
23
+ worktreeContextNote,
24
+ type EphemeralSubagentResult,
25
+ type EphemeralSubagentTimeout,
26
+ type Role,
27
+ type WorktreeInfo,
28
+ type WorktreePayload,
29
+ } from "@henryqw/pi-subagent";
30
+ import { DEFAULT_TIMEOUT_CONFIG, readSubagentConfig, type SubagentTimeoutConfig } from "./config.ts";
31
+ import {
32
+ formatBackgroundWorkflowResult,
33
+ formatWorkflowResult,
34
+ formatWorkflowUpdate,
35
+ WorkflowAbortedError,
36
+ WorkflowFailureError,
37
+ type WorkflowTransportEntry,
38
+ } from "./result-transport.ts";
39
+ import {
40
+ identifyWorkflowEntries,
41
+ parseWorkflow,
42
+ runForegroundWorkflow,
43
+ WorkflowSchema,
44
+ type Delegation,
45
+ type ParsedWorkflow,
46
+ type WorkflowEntry,
47
+ } from "./workflow.ts";
22
48
 
23
49
  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
50
  const WIDGET_KEY = "subagent-status";
54
51
  const WIDGET_INTERVAL_MS = 80;
55
52
  const TERMINAL_DISPLAY_MS = 1_000;
@@ -60,7 +57,7 @@ const DEFAULT_TIMEOUT_POLICY = {
60
57
  };
61
58
  const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
62
59
 
63
- type TimeoutPolicy = typeof DEFAULT_TIMEOUT_POLICY;
60
+ type TimeoutPolicy = EphemeralSubagentTimeout;
64
61
 
65
62
  /** Merge validated config-file timeout fields over defaults; absent keys keep defaults. */
66
63
  export function resolveTimeoutPolicy(partial: SubagentTimeoutConfig | undefined): TimeoutPolicy {
@@ -69,19 +66,6 @@ export function resolveTimeoutPolicy(partial: SubagentTimeoutConfig | undefined)
69
66
  maxMs: partial?.maxMinutes === undefined ? DEFAULT_TIMEOUT_POLICY.maxMs : partial.maxMinutes * 60_000,
70
67
  };
71
68
  }
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
69
  type WidgetStatus = "working" | "success" | "failure" | "aborted";
86
70
  type WidgetItem = {
87
71
  roleRoute: string;
@@ -93,80 +77,10 @@ type WidgetItem = {
93
77
  removeAt?: number;
94
78
  };
95
79
 
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
80
  function taskSummary(task: string): string {
157
81
  return task.replace(/[\u0000-\u001f\u007f-\u009f]/g, " ").trim().split(/\s+/).slice(0, 4).join(" ");
158
82
  }
159
83
 
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
84
  function formatTokens(tokens: number): string {
171
85
  if (tokens < 1_000) return String(tokens);
172
86
  if (tokens < 100_000) return `${(tokens / 1_000).toFixed(1)}k`;
@@ -174,19 +88,6 @@ function formatTokens(tokens: number): string {
174
88
  return `${(tokens / 1_000_000).toFixed(1)}M`;
175
89
  }
176
90
 
177
- function formatElapsed(startedAt: number, finishedAt = Date.now()): string {
178
- const seconds = Math.max(0, Math.floor((finishedAt - startedAt) / 1_000));
179
- const hours = Math.floor(seconds / 3_600);
180
- const minutes = Math.floor(seconds % 3_600 / 60);
181
- return hours ? `${hours}h ${minutes}m` : minutes ? `${minutes}m ${seconds % 60}s` : `${seconds}s`;
182
- }
183
-
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
91
  function statusGlyph(status: WidgetStatus, spinnerIndex: number, theme: Theme): string {
191
92
  switch (status) {
192
93
  case "working": return theme.fg("accent", SPINNER_FRAMES[spinnerIndex % SPINNER_FRAMES.length]!);
@@ -214,7 +115,7 @@ function renderWidgetRows(
214
115
  const visible = items.slice(0, MAX_WIDGET_ROWS);
215
116
  if (!visible.length) return [];
216
117
  const tokens = visible.map((item) => formatTokens(item.tokens));
217
- const elapsed = visible.map((item) => formatElapsed(item.startedAt, item.finishedAt ?? now));
118
+ const elapsed = visible.map((item) => formatDuration((item.finishedAt ?? now) - item.startedAt));
218
119
  const tokenWidth = Math.max(...tokens.map(visibleWidth));
219
120
  const elapsedWidth = Math.max(...elapsed.map(visibleWidth));
220
121
  const fixedWidth = 1 + 8 + tokenWidth + elapsedWidth;
@@ -240,270 +141,6 @@ function renderWidgetRows(
240
141
  return lines;
241
142
  }
242
143
 
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
144
  function resolveDesignatedRoute(ctx: ExtensionContext, reference: string, thinking?: ThinkingLevel): ResolvedTaskRoute {
508
145
  const models = availableTaskModels(ctx);
509
146
  const model = resolveAvailableModel(models, reference, ctx.model?.provider);
@@ -511,7 +148,7 @@ function resolveDesignatedRoute(ctx: ExtensionContext, reference: string, thinki
511
148
  throw new Error(`Unknown delegate_task model: ${reference}. Available models: ${models.map((candidate) => modelReference(candidate)).join(", ") || "none"}.`);
512
149
  }
513
150
  const levels = taskThinkingLevels(ctx, model);
514
- if (thinking !== undefined) {
151
+ if (thinking !== undefined) {
515
152
  if (!levels.includes(thinking)) {
516
153
  throw new Error(`delegate_task thinking ${thinking} is not usable for ${modelReference(model)} in this session. Usable levels here: ${levels.join(", ") || "none"}.`);
517
154
  }
@@ -525,6 +162,20 @@ function resolveDesignatedRoute(ctx: ExtensionContext, reference: string, thinki
525
162
 
526
163
  const BACKGROUND_RESULT_TYPE = "subagent-background-result";
527
164
 
165
+ function boundedError(error: unknown): Error {
166
+ const message = capOutput(error instanceof Error ? error.message : String(error));
167
+ return error instanceof Error && error.message === message ? error : new Error(message, { cause: error });
168
+ }
169
+
170
+ function failedToolPatch(error: WorkflowFailureError | WorkflowAbortedError) {
171
+ return {
172
+ content: [{ type: "text" as const, text: error.message }],
173
+ details: error.details,
174
+ isError: true as const,
175
+ ...(error.usage === undefined ? {} : { usage: error.usage }),
176
+ };
177
+ }
178
+
528
179
  const roleSummary = (): string => {
529
180
  try {
530
181
  const roles = loadRoles();
@@ -551,11 +202,11 @@ export default function subagentExtension(
551
202
  // Reject "2workers", "1.5", "1e3" — parseInt would silently accept prefixes —
552
203
  // and digit strings that overflow to Infinity, which would disable the cap.
553
204
  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)}.`);
205
+ throw boundedError(new Error(`PI_SUBAGENT_MAX_SUBAGENTS must be a positive integer, got ${JSON.stringify(maxSubagentsRaw)}.`));
555
206
  }
556
207
  const parsed = Number.parseInt(maxSubagentsRaw, 10);
557
208
  if (!Number.isSafeInteger(parsed)) {
558
- throw new Error(`PI_SUBAGENT_MAX_SUBAGENTS exceeds the supported range, got ${JSON.stringify(maxSubagentsRaw)}.`);
209
+ throw boundedError(new Error(`PI_SUBAGENT_MAX_SUBAGENTS exceeds the supported range, got ${JSON.stringify(maxSubagentsRaw)}.`));
559
210
  }
560
211
  maxActiveSubagents = parsed;
561
212
  }
@@ -563,44 +214,17 @@ export default function subagentExtension(
563
214
  // Explicit policy argument (tests/embedders) wins; otherwise resolve from
564
215
  // config file over defaults.
565
216
  const timeoutPolicy: TimeoutPolicy = overrideTimeoutPolicy ?? resolveTimeoutPolicy(loadedConfig.config.timeout);
217
+ const executor = createEphemeralSubagentExecutor({ maxConcurrency: maxActiveSubagents, timeout: timeoutPolicy });
566
218
  // Background children outlive the launching tool call, so they get their own
567
219
  // abort signal: tied to the session, not to the turn that started them.
568
220
  const backgroundTasks = new Map<string, { controller: AbortController; settled: Promise<void> }>();
221
+ const failedToolPatches = new Map<string, ReturnType<typeof failedToolPatch>>();
569
222
  // Latest known session context; refreshed on session lifecycle and model
570
223
  // changes so queued background launches resolve against effective state.
571
224
  let latestCtx: ExtensionContext | undefined;
572
225
  // Bumped by session_start and session_shutdown; background tasks may only
573
226
  // deliver into the exact session that launched them.
574
227
  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
228
  let widgetInstalled = false;
605
229
  let widgetTimer: ReturnType<typeof setInterval> | undefined;
606
230
  let spinnerIndex = 0;
@@ -684,6 +308,7 @@ export default function subagentExtension(
684
308
  for (const warning of startupWarnings.splice(0)) ctx.ui.notify(warning, "warning");
685
309
  });
686
310
  pi.on("session_shutdown", async (_event, ctx) => {
311
+ failedToolPatches.clear();
687
312
  stopWidgetTimer();
688
313
  widgetItems.clear();
689
314
  activeTui = undefined;
@@ -705,75 +330,130 @@ export default function subagentExtension(
705
330
  pi.on("agent_settled", (_event, ctx) => {
706
331
  latestCtx = ctx;
707
332
  });
333
+ pi.on("tool_result", (event) => {
334
+ if (event.toolName !== "delegate_task") return;
335
+ const patch = failedToolPatches.get(event.toolCallId);
336
+ if (!patch) return;
337
+ failedToolPatches.delete(event.toolCallId);
338
+ return patch;
339
+ });
708
340
 
709
- const reportBackground = async (
341
+ const reportBackground = (
710
342
  launchEpoch: number,
711
343
  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> => {
344
+ mode: ParsedWorkflow["mode"],
345
+ entries: readonly WorkflowTransportEntry[],
346
+ setupRecoveries: ReadonlyMap<string, string>,
347
+ ): void => {
718
348
  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.
349
+ const retained = entries.filter(({ worktreePayload }) => worktreePayload && !worktreePayload.pruned);
350
+ if (stale && !retained.length && !setupRecoveries.size) return;
351
+ const transport = formatBackgroundWorkflowResult(mode, entries);
352
+ const outcome = stale ? "aborted" : transport.failed ? "failed" : "completed";
353
+ const content = stale
354
+ ? capOutput([
355
+ "Background workflow left recoverable isolated work after session shutdown.",
356
+ `Task ID: ${taskId}`,
357
+ `Mode: ${mode}`,
358
+ "Recovery locations:",
359
+ ...retained.map((entry) =>
360
+ `- [${entry.index}] worktree path=${JSON.stringify(entry.worktreePayload!.path)} branch=${JSON.stringify(entry.worktreePayload!.branch)}`),
361
+ ...[...setupRecoveries].map(([id, recovery]) => {
362
+ const entry = entries.find((candidate) => candidate.id === id)!;
363
+ return `- [${entry.index}] setup state: ${recovery}`;
364
+ }),
365
+ "Evidence:",
366
+ ...retained.map((entry) => {
367
+ const payload = entry.worktreePayload!;
368
+ return `- [${entry.index}] retained worktree commits=${payload.commits} dirty=${payload.dirty} inspection_failed=${payload.inspection_failed === true}`;
369
+ }),
370
+ ...[...setupRecoveries].map(([id, recovery]) => {
371
+ const entry = entries.find((candidate) => candidate.id === id)!;
372
+ return `- [${entry.index}] recoverable WorktreeSetupError: ${recovery}`;
373
+ }),
374
+ ].join("\n"))
375
+ : transport.text;
722
376
  try {
377
+ // Custom messages convert to user-role LLM messages, so the parent agent
378
+ // sees the aggregate on its next turn without forcing one now.
723
379
  pi.sendMessage({
724
380
  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)}` : ""}`,
381
+ content,
730
382
  display: true,
731
- details: { ...details, taskId, outcome, ...(stale ? { recovery: true } : {}) },
383
+ details: {
384
+ ...transport.details,
385
+ taskId,
386
+ outcome,
387
+ ...(transport.usage === undefined ? {} : { usage: transport.usage }),
388
+ ...(stale ? { recovery: true } : {}),
389
+ },
732
390
  }, { triggerTurn: false });
733
- } catch {
734
- // Session may already be gone; the widget row still shows the outcome.
391
+ } catch (error) {
392
+ // Delivery can disappear during teardown; only an active UI gets a visible failure.
393
+ if (!stale && latestCtx?.hasUI) {
394
+ latestCtx.ui.notify(boundedError(new Error(
395
+ `Background workflow ${taskId} result delivery failed: ${error instanceof Error ? error.message : String(error)}`,
396
+ )).message, "error");
397
+ }
735
398
  }
736
399
  };
737
400
 
738
401
  pi.registerTool({
739
402
  name: "delegate_task",
740
403
  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",
404
+ description: `Delegate one selected single, parallel, or chain workflow of bounded tasks to isolated Pi Subagents. Roles: ${roleSummary()}.`,
405
+ promptSnippet: "Delegate one bounded single, parallel, or chain workflow to isolated roles",
743
406
  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.",
407
+ "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.",
408
+ "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.",
409
+ "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.",
410
+ "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.",
411
+ "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
412
  ],
750
- parameters: Parameters,
413
+ parameters: WorkflowSchema,
414
+ prepareArguments(args) {
415
+ try {
416
+ const workflow = parseWorkflow(args);
417
+ if (workflow.mode === "single") return { ...workflow.delegations[0], background: workflow.background };
418
+ if (workflow.mode === "parallel") return { tasks: workflow.delegations, background: workflow.background };
419
+ return { chain: workflow.delegations, background: workflow.background };
420
+ } catch (error) {
421
+ throw boundedError(error);
422
+ }
423
+ },
751
424
  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"}.`);
425
+ const throwIfAborted = () => {
426
+ if (signal?.aborted) throw new EphemeralSubagentError("aborted", "Subagent was aborted.", signal.reason);
427
+ };
428
+ throwIfAborted();
429
+ let workflow: ParsedWorkflow;
430
+ let roles: Role[];
431
+ try {
432
+ workflow = parseWorkflow(params);
433
+ roles = loadRoles();
434
+ const knownRoles = new Set(roles.map(({ name }) => name));
435
+ for (const { role } of workflow.delegations) {
436
+ if (!knownRoles.has(role)) {
437
+ throw new Error(`Unknown Subagent role: ${role}. Available roles: ${roles.map(({ name }) => name).join(", ") || "none"}.`);
438
+ }
439
+ }
440
+ } catch (error) {
441
+ throw boundedError(error);
757
442
  }
443
+ throwIfAborted();
444
+ const rolesByName = new Map(roles.map((role) => [role.name, role]));
758
445
 
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.
446
+ // Resolve against the latest known session context after each FIFO permit.
765
447
  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(), {
448
+ const resolveLaunch = (role: Role, delegation: Delegation) => createRoleLaunch(pi, launchCtx(), {
769
449
  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),
450
+ route: delegation.model !== undefined
451
+ ? resolveDesignatedRoute(launchCtx(), delegation.model, delegation.thinking)
452
+ : delegation.modelClass === undefined
453
+ ? resolveConfiguredTaskRoute(launchCtx(), SUBAGENT_TASK, undefined, delegation.thinking)
454
+ : resolveTaskRoute(launchCtx(), delegation.modelClass, undefined, delegation.thinking),
775
455
  });
776
- const notifyMissingSkills = (launch: ReturnType<typeof resolveLaunch>) => {
456
+ const notifyMissingSkills = (role: Role, launch: ReturnType<typeof resolveLaunch>) => {
777
457
  if (launch.missingSkills.length) {
778
458
  ctx.ui.notify(
779
459
  `Subagent role ${role.name} skipped unavailable Pi skills: ${launch.missingSkills.join(", ")}.`,
@@ -782,128 +462,233 @@ export default function subagentExtension(
782
462
  }
783
463
  };
784
464
 
785
- if (params.background) {
465
+ const foregroundWorkflow: ParsedWorkflow = { ...workflow, background: false };
466
+ const entries = identifyWorkflowEntries(toolCallId, foregroundWorkflow);
467
+ const states = new Map<string, WorkflowTransportEntry>(entries.map((entry) => [entry.id, {
468
+ id: entry.id,
469
+ index: entry.index,
470
+ role: entry.delegation.role,
471
+ status: "pending",
472
+ }]));
473
+ const setupRecoveries = new Map<string, string>();
474
+ const emitUpdate = (enabled: boolean) => {
475
+ if (!enabled) return;
476
+ const update = formatWorkflowUpdate(workflow.mode, [...states.values()]);
477
+ onUpdate?.({
478
+ content: [{ type: "text", text: update.text }],
479
+ details: update.details,
480
+ ...(update.usage === undefined ? {} : { usage: update.usage }),
481
+ });
482
+ };
483
+ const runWorkflow = async (workflowSignal: AbortSignal | undefined, emitToolUpdates: boolean) => {
484
+ try {
485
+ return await runForegroundWorkflow<EphemeralSubagentResult>(toolCallId, foregroundWorkflow, async (entry: WorkflowEntry) => {
486
+ const role = rolesByName.get(entry.delegation.role)!;
487
+ let model: string | undefined;
488
+ let thinkingLevel: string | undefined;
489
+ let worktree: WorktreeInfo | undefined;
490
+ let worktreePayload: WorktreePayload | undefined;
491
+ let child: EphemeralSubagentResult | undefined;
492
+ let rejected: unknown;
493
+ let rejectedUsage: Usage | undefined;
494
+ let aborted = false;
495
+ let status: "succeeded" | "failed" | "rejected" = "rejected";
496
+ let text = "Subagent did not start.";
497
+ const setState = (
498
+ nextStatus: "running" | "succeeded" | "failed" | "rejected",
499
+ nextText: string,
500
+ ) => {
501
+ const usage = child?.usage ?? rejectedUsage;
502
+ const base = {
503
+ id: entry.id,
504
+ index: entry.index,
505
+ role: role.name,
506
+ ...(model === undefined ? {} : { model }),
507
+ ...(thinkingLevel === undefined ? {} : { thinkingLevel }),
508
+ ...(worktreePayload === undefined ? {} : { worktreePayload }),
509
+ ...(usage === undefined ? {} : { usage }),
510
+ };
511
+ states.set(entry.id, nextStatus === "failed" || nextStatus === "rejected"
512
+ ? { ...base, status: nextStatus, failure: nextText }
513
+ : { ...base, status: nextStatus, assistantOutput: nextText });
514
+ };
515
+ try {
516
+ child = await executor.run({
517
+ signal: workflowSignal,
518
+ onUpdate: (output) => {
519
+ setState("running", output);
520
+ emitUpdate(emitToolUpdates);
521
+ },
522
+ onTokens: (tokens) => updateWidgetTokens(entry.id, tokens),
523
+ prepare: async () => {
524
+ // Route and effective Role resources resolve only after this entry's
525
+ // shared executor permit, before isolated state is created.
526
+ const launch = resolveLaunch(role, entry.delegation);
527
+ notifyMissingSkills(role, launch);
528
+ model = modelReference(launch.model);
529
+ thinkingLevel = launch.thinkingLevel;
530
+ if (role.isolation === "worktree") {
531
+ worktree = await createChildWorktree(ctx.cwd, entry.id, undefined, workflowSignal);
532
+ }
533
+ startWidgetItem(entry.id, role.name, launch.model.id, launch.thinkingLevel, entry.delegation.task, ctx);
534
+ setState("running", "");
535
+ emitUpdate(emitToolUpdates);
536
+ return {
537
+ launch,
538
+ task: worktree ? `${entry.delegation.task}${worktreeContextNote(worktree)}` : entry.delegation.task,
539
+ cwd: worktree?.cwd ?? ctx.cwd,
540
+ };
541
+ },
542
+ });
543
+ if (child.outcome === "failure") {
544
+ status = "failed";
545
+ text = capOutput(child.errorMessage || child.stderr.trim() || child.output || `Subagent exited with code ${child.exitCode}.`);
546
+ } else {
547
+ status = "succeeded";
548
+ text = child.output;
549
+ }
550
+ } catch (error) {
551
+ rejected = error;
552
+ aborted = error instanceof EphemeralSubagentError && error.code === "aborted";
553
+ rejectedUsage = error instanceof EphemeralSubagentError
554
+ ? (error as EphemeralSubagentError & { usage?: Usage }).usage
555
+ : undefined;
556
+ const cause = error instanceof EphemeralSubagentError ? error.cause : error;
557
+ if (cause instanceof Error && cause.name === "WorktreeSetupError") {
558
+ setupRecoveries.set(entry.id, cause.message);
559
+ }
560
+ text = capOutput(error instanceof Error ? error.message : String(error));
561
+ }
562
+ try {
563
+ worktreePayload = worktree ? await finalizeChildWorktree(worktree) : undefined;
564
+ } catch (error) {
565
+ rejected = error;
566
+ aborted = false;
567
+ status = "rejected";
568
+ text = capOutput(error instanceof Error ? error.message : String(error));
569
+ worktreePayload = worktree ? {
570
+ path: worktree.path,
571
+ branch: worktree.branch,
572
+ commits: 0,
573
+ dirty: false,
574
+ pruned: false,
575
+ inspection_failed: true,
576
+ note: capOutput(`Worktree finalization failed (${text}); commits/dirty UNKNOWN. Inspect retained work before assuming no changes.`),
577
+ } : undefined;
578
+ }
579
+ if (worktreePayload?.inspection_failed) {
580
+ const note = capOutput(worktreePayload.note ?? `Worktree inspection failed; inspect ${worktreePayload.path} before assuming no work.`);
581
+ worktreePayload = { ...worktreePayload, note };
582
+ rejected = new Error(note, rejected === undefined ? undefined : { cause: rejected });
583
+ status = "rejected";
584
+ text = capOutput(`${note}\n${text}`);
585
+ }
586
+ if (rejected !== undefined) status = "rejected";
587
+ setState(status, text);
588
+ try {
589
+ finishWidgetItem(entry.id, aborted ? "aborted" : status === "succeeded" ? "success" : "failure");
590
+ emitUpdate(emitToolUpdates);
591
+ } catch (error) {
592
+ rejected = error;
593
+ status = "rejected";
594
+ setState("rejected", capOutput(error instanceof Error ? error.message : String(error)));
595
+ finishWidgetItem(entry.id, "failure");
596
+ }
597
+ if (rejected !== undefined) throw rejected;
598
+ return child!.outcome === "success"
599
+ ? { ok: true, assistantOutput: text, result: child! }
600
+ : { ok: false, result: child! };
601
+ }, workflowSignal);
602
+ } finally {
603
+ if (workflow.mode === "chain" && (workflowSignal?.aborted
604
+ || [...states.values()].some(({ status }) => status === "failed" || status === "rejected"))) {
605
+ for (const [id, state] of states) {
606
+ if (state.status === "pending") states.set(id, { ...state, status: "skipped" });
607
+ }
608
+ }
609
+ }
610
+ };
611
+
612
+ const recordInfrastructureFailure = (error: unknown) => {
613
+ if ([...states.values()].some(({ status }) => status === "failed" || status === "rejected")) return;
614
+ const target = [...states.values()].find(({ status }) => status === "pending" || status === "running")
615
+ ?? [...states.values()].at(-1)!;
616
+ states.set(target.id, {
617
+ id: target.id,
618
+ index: target.index,
619
+ role: target.role,
620
+ ...(target.model === undefined ? {} : { model: target.model }),
621
+ ...(target.thinkingLevel === undefined ? {} : { thinkingLevel: target.thinkingLevel }),
622
+ ...(target.worktreePayload === undefined ? {} : { worktreePayload: target.worktreePayload }),
623
+ ...(target.usage === undefined ? {} : { usage: target.usage }),
624
+ status: "rejected",
625
+ failure: capOutput(error instanceof Error ? error.message : String(error)),
626
+ });
627
+ };
628
+
629
+ throwIfAborted();
630
+ if (workflow.background) {
786
631
  const taskId = `bg-${++backgroundSequence}-${Date.now().toString(36)}`;
787
632
  const controller = new AbortController();
788
633
  // Freeze the launching session now: a task that settles after a
789
634
  // reload must not deliver into whichever session is active then.
790
635
  const launchEpoch = sessionEpoch;
791
636
  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
637
  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
- );
638
+ // Let the acknowledgement resolve before any route, Skill, worktree,
639
+ // permit, or child work starts.
640
+ await new Promise<void>((resolve) => setImmediate(resolve));
641
+ try {
642
+ await runWorkflow(controller.signal, false);
643
+ } catch (error) {
644
+ if (!controller.signal.aborted) recordInfrastructureFailure(error);
645
+ }
646
+ reportBackground(launchEpoch, taskId, workflow.mode, [...states.values()], setupRecoveries);
846
647
  } finally {
847
- if (acquired) releaseChildPermit();
848
- finishWidgetItem(taskId, widgetStatus);
849
648
  backgroundTasks.delete(taskId);
850
649
  }
851
650
  })();
852
651
  backgroundTasks.set(taskId, { controller, settled });
853
652
  void settled;
653
+ const acknowledgement = capOutput([
654
+ `Background workflow ${taskId} accepted.`,
655
+ `Mode: ${workflow.mode}`,
656
+ "Entries:",
657
+ ...entries.map((entry) =>
658
+ `- [${entry.index}] id=${JSON.stringify(entry.id)} role=${JSON.stringify(entry.delegation.role)}`),
659
+ "The aggregate outcome arrives as one message; keep working or end your turn.",
660
+ ].join("\n"));
854
661
  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 },
662
+ content: [{ type: "text" as const, text: acknowledgement }],
663
+ details: {
664
+ taskId,
665
+ background: true,
666
+ mode: workflow.mode,
667
+ entries: entries.map((entry) => ({ id: entry.id, index: entry.index, role: entry.delegation.role })),
668
+ },
857
669
  };
858
670
  }
859
671
 
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;
672
+ let outcomes: Awaited<ReturnType<typeof runWorkflow>>;
870
673
  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;
674
+ outcomes = await runWorkflow(signal, true);
890
675
  } 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)}`;
676
+ if (!signal?.aborted) throw error;
677
+ const aborted = new WorkflowAbortedError(workflow.mode, [...states.values()], signal.reason);
678
+ failedToolPatches.set(toolCallId, failedToolPatch(aborted));
679
+ throw aborted;
898
680
  }
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;
681
+ if (outcomes.some(({ status }) => status === "failed" || status === "rejected")) {
682
+ const failure = new WorkflowFailureError(workflow.mode, [...states.values()]);
683
+ failedToolPatches.set(toolCallId, failedToolPatch(failure));
684
+ throw failure;
905
685
  }
906
- return result!;
686
+ const result = formatWorkflowResult(workflow.mode, [...states.values()]);
687
+ return {
688
+ content: [{ type: "text" as const, text: result.text }],
689
+ details: result.details,
690
+ ...(result.usage === undefined ? {} : { usage: result.usage }),
691
+ };
907
692
  },
908
693
  });
909
694
  }