pi-subagents 0.59.0 → 0.61.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.
Files changed (72) hide show
  1. package/CHANGELOG.md +69 -0
  2. package/docs/agents.md +2 -2
  3. package/docs/configuration.md +9 -5
  4. package/docs/extension-api.md +14 -7
  5. package/docs/models.md +1 -1
  6. package/docs/observability.md +1 -1
  7. package/docs/tool-reference.md +15 -3
  8. package/docs/workflows.md +15 -14
  9. package/install.mjs +2 -1
  10. package/package.json +1 -1
  11. package/skills/council-mode/SKILL.md +48 -243
  12. package/skills/council-mode/references/pass-contracts.md +150 -0
  13. package/skills/pi-subagents/SKILL.md +89 -37
  14. package/skills/pi-subagents/references/constraints-and-recipes.md +30 -234
  15. package/skills/pi-subagents/references/execution-controls.md +79 -7
  16. package/skills/pi-subagents/references/management-authoring-rpc.md +2 -2
  17. package/skills/pi-subagents/references/multi-lane-orchestration.md +13 -1
  18. package/skills/pi-subagents/references/prompting-and-roles.md +35 -28
  19. package/skills/pi-subagents/references/review-and-validation.md +73 -0
  20. package/src/agents/agent-management.ts +256 -88
  21. package/src/agents/agents.ts +527 -221
  22. package/src/api/background-work.ts +7 -2
  23. package/src/api/external-runs.ts +67 -4
  24. package/src/api/preflight.ts +13 -8
  25. package/src/api/shared-types.ts +3 -0
  26. package/src/extension/index.ts +7 -4
  27. package/src/extension/public-execution.ts +48 -4
  28. package/src/extension/rpc.ts +62 -4
  29. package/src/extension/schemas.ts +12 -7
  30. package/src/extension/tool-description.ts +16 -16
  31. package/src/runs/background/async-execution.ts +53 -37
  32. package/src/runs/background/async-job-tracker.ts +65 -3
  33. package/src/runs/background/async-resume.ts +3 -1
  34. package/src/runs/background/async-status.ts +104 -11
  35. package/src/runs/background/auto-drain.ts +1 -1
  36. package/src/runs/background/control-channel.ts +3 -2
  37. package/src/runs/background/fleet-view.ts +1 -1
  38. package/src/runs/background/result-watcher.ts +1 -1
  39. package/src/runs/background/resume-guidance.ts +1 -1
  40. package/src/runs/background/run-status.ts +15 -4
  41. package/src/runs/background/subagent-runner.ts +6 -3
  42. package/src/runs/background/subagent-wait.ts +30 -23
  43. package/src/runs/background/wait-completions.ts +4 -1
  44. package/src/runs/background/wait-tool.ts +24 -18
  45. package/src/runs/foreground/execution.ts +72 -4
  46. package/src/runs/foreground/subagent-executor.ts +276 -59
  47. package/src/runs/shared/acceptance.ts +43 -18
  48. package/src/runs/shared/async-status-projection.ts +138 -4
  49. package/src/runs/shared/background-process-options.ts +9 -0
  50. package/src/runs/shared/host-step-status.ts +1 -0
  51. package/src/runs/shared/mcp-direct-tool-grant.ts +2 -5
  52. package/src/runs/shared/model-fallback.ts +61 -17
  53. package/src/runs/shared/mutation-evidence.ts +52 -3
  54. package/src/runs/shared/permissions.ts +1 -1
  55. package/src/runs/shared/pi-args.ts +47 -1
  56. package/src/runs/shared/single-output.ts +45 -18
  57. package/src/runs/shared/subagent-prompt-runtime.ts +20 -2
  58. package/src/runs/shared/tool-timeout.ts +1 -1
  59. package/src/runs/shared/workflow-graph.ts +16 -0
  60. package/src/shared/types.ts +74 -4
  61. package/src/shared/workflow-child-permit.ts +91 -0
  62. package/src/slash/prompt-template-bridge.ts +37 -1
  63. package/src/slash/slash-commands.ts +18 -26
  64. package/src/tui/fleet-status.ts +11 -3
  65. package/src/tui/render-helpers.ts +31 -0
  66. package/src/tui/render.ts +673 -145
  67. package/src/watchdog/change-signature.ts +40 -1
  68. package/src/workflows/host-command.ts +6 -1
  69. package/src/workflows/scripted-workflow.ts +206 -6
  70. package/src/workflows/workflow-child-summary.ts +1 -1
  71. package/src/workflows/workflow-receipt.ts +41 -4
  72. package/src/workflows/workflow-resources.ts +150 -0
@@ -1,8 +1,9 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
3
  import { formatDuration, formatModelThinking, formatTokens, shortenPath } from "../../shared/formatters.ts";
4
+ import { previewDisplayText } from "../../shared/display-text.ts";
4
5
  import { formatActivityLabel, formatParallelOutcome } from "../../shared/status-format.ts";
5
- import { type ActivityState, type AsyncJobStep, type AsyncParallelGroupStatus, type AsyncStatus, type CostSummary, type Details, type HostStepNodeV1, type HostStepState, type LaunchResolvedChildExtensionsV1, type RuntimeAcknowledgedChildExtensionsV1, type NestedRunSummary, type SteeringStatus, type SubagentRunMode, type TokenUsage, type TurnBudgetState, type UsageBudgetState, type WorkflowPreflightV1 } from "../../shared/types.ts";
6
+ import { type ActivityState, type AsyncJobStep, type AsyncParallelGroupStatus, type AsyncStatus, type CostSummary, type Details, type HostStepNodeV1, type HostStepState, type LaunchResolvedChildExtensionsV1, type RuntimeAcknowledgedChildExtensionsV1, type NestedRunSummary, type SteeringStatus, type SubagentRunMode, type TimeoutRecoveryProjection, type TokenUsage, type TurnBudgetState, type UsageBudgetState, type WorkflowPreflightV1, type WorkflowGraphSnapshot } from "../../shared/types.ts";
6
7
  import type { ResolvedSubagentCapabilityCeiling, SubagentCapabilityAudit } from "../shared/capability-ceiling.ts";
7
8
  import { readStatus } from "../../shared/utils.ts";
8
9
  import { attachRootChildrenToSteps, buildNestedRouteIndex, findNestedRouteForRootId, type NestedRoute, projectNestedEvents } from "../shared/nested-events.ts";
@@ -21,6 +22,8 @@ import { assertWorkflowGraphHostSteps, hostStepReportName, hostStepVerdictLabel,
21
22
  import { projectAsyncWorkflowRows } from "../shared/async-status-projection.ts";
22
23
  import { validateAsyncStatusLaneMetadata } from "../shared/lane-metadata.ts";
23
24
  import { formatWorkflowPreflightPlanSummary, formatWorkflowPreflightWarningSummary } from "../../workflows/workflow-preflight.ts";
25
+ import { workflowGraphStageNodes } from "../shared/workflow-graph.ts";
26
+ import { formatTimeoutRecoveryLines, projectTimeoutRecovery } from "../shared/mutation-evidence.ts";
24
27
 
25
28
  interface AsyncRunStepSummary {
26
29
  index: number;
@@ -75,6 +78,7 @@ interface AsyncRunStepSummary {
75
78
  review?: AsyncJobStep["review"];
76
79
  effects?: AsyncJobStep["effects"];
77
80
  processTerminal?: AsyncJobStep["processTerminal"];
81
+ timeoutRecovery?: TimeoutRecoveryProjection;
78
82
  launchResolvedExtensions?: LaunchResolvedChildExtensionsV1;
79
83
  runtimeAcknowledgedExtensions?: RuntimeAcknowledgedChildExtensionsV1;
80
84
  capabilityCeiling?: ResolvedSubagentCapabilityCeiling;
@@ -116,6 +120,7 @@ export interface AsyncRunSummary {
116
120
  pendingAppends?: number;
117
121
  parallelGroups?: AsyncParallelGroupStatus[];
118
122
  hostSteps?: HostStepNodeV1[];
123
+ workflowGraph?: AsyncStatus["workflowGraph"];
119
124
  steps: AsyncRunStepSummary[];
120
125
  sessionDir?: string;
121
126
  outputFile?: string;
@@ -161,6 +166,40 @@ function getErrorMessage(error: unknown): string {
161
166
  return error instanceof Error ? error.message : String(error);
162
167
  }
163
168
 
169
+ function isAsyncStatusIsolationError(asyncDir: string, error: unknown): boolean {
170
+ const statusPath = path.join(asyncDir, "status.json");
171
+ const message = getErrorMessage(error);
172
+ return /^(?:Failed to (?:inspect|read|parse|validate) async status file|Invalid async status file) '/.test(message)
173
+ || message.startsWith(`Invalid async status '${statusPath}'`)
174
+ || message.startsWith(`Invalid host step '${statusPath}`)
175
+ || /^(workflowChildren|Invalid workflowChildren)/.test(message);
176
+ }
177
+
178
+ function isolateCorruptActiveRun(asyncDir: string, runId: string, error: unknown, now?: () => number): void {
179
+ const statusPath = path.join(asyncDir, "status.json");
180
+ const processTerminal = readProcessTerminal(asyncDir, { runId });
181
+ let markerAge: number | undefined;
182
+ try {
183
+ markerAge = activeRunMarkerAgeMs(asyncDir, now?.());
184
+ } catch (markerError) {
185
+ console.error(`Failed to inspect corrupt async active-run marker for '${runId}':`, markerError);
186
+ }
187
+ const markerCanBeReleased = processTerminal?.state === "observed"
188
+ || (markerAge !== undefined && markerAge > DEFAULT_STALE_TERMINAL_ACTIVE_MARKER_MS);
189
+ let markerAction = "active marker retained because runner liveness is unknown";
190
+ if (markerCanBeReleased) {
191
+ try {
192
+ releaseActiveRunIndex(asyncDir);
193
+ markerAction = processTerminal?.state === "observed"
194
+ ? "active marker released after observed process-terminal proof"
195
+ : "stale active marker released";
196
+ } catch (releaseError) {
197
+ markerAction = `failed to release active marker: ${getErrorMessage(releaseError)}`;
198
+ }
199
+ }
200
+ console.error(`[pi-subagents] Skipping corrupt active async run '${runId}' at '${statusPath}': ${getErrorMessage(error)}; ${markerAction}.`);
201
+ }
202
+
164
203
  function isNotFoundError(error: unknown): boolean {
165
204
  return typeof error === "object"
166
205
  && error !== null
@@ -245,14 +284,16 @@ function deriveAsyncActivityState(asyncDir: string, status: AsyncStatus): { acti
245
284
  }
246
285
 
247
286
  function statusToSummary(asyncDir: string, status: AsyncStatus & { cwd?: string }, nestedWarnings: string[] = [], nestedRoute?: NestedRoute): AsyncRunSummary {
248
- validateAsyncStatusLaneMetadata(status, `Invalid async status '${path.join(asyncDir, "status.json")}'`);
287
+ const statusPath = path.join(asyncDir, "status.json");
288
+ validateAsyncStatusLaneMetadata(status, `Invalid async status '${statusPath}'`);
249
289
  const workflowChildren = parseWorkflowChildSummary(status.workflowChildren);
250
- if (workflowChildren && workflowChildren.workflowRunId !== status.runId) throw new Error(`Invalid async status '${path.join(asyncDir, "status.json")}': workflowChildren.workflowRunId does not match.`);
251
- assertWorkflowGraphHostSteps(status.workflowGraph, path.join(asyncDir, "status.json"), status.runId);
290
+ if (workflowChildren && workflowChildren.workflowRunId !== status.runId) throw new Error(`Invalid async status '${statusPath}': workflowChildren.workflowRunId does not match.`);
291
+ assertWorkflowGraphHostSteps(status.workflowGraph, statusPath, status.runId);
252
292
  const hostSteps = validHostStepNodes(status.workflowGraph);
253
293
  if (status.sessionId !== undefined && typeof status.sessionId !== "string") {
254
- throw new Error(`Invalid async status '${path.join(asyncDir, "status.json")}': sessionId must be a string.`);
294
+ throw new Error(`Invalid async status '${statusPath}': sessionId must be a string.`);
255
295
  }
296
+ if (status.outputFile !== undefined && typeof status.outputFile !== "string") throw new Error(`Invalid async status '${statusPath}': outputFile must be a string.`);
256
297
  const { activityState, lastActivityAt } = deriveAsyncActivityState(asyncDir, status);
257
298
  const processTerminal = readProcessTerminal(asyncDir, { runId: status.runId, runnerProcessInstanceId: status.processTerminal?.runnerProcessInstanceId })
258
299
  ?? sanitizeProcessTerminal(status.processTerminal, { runId: status.runId, runnerProcessInstanceId: status.processTerminal?.runnerProcessInstanceId }, path.join(asyncDir, "status.json"));
@@ -282,6 +323,7 @@ function statusToSummary(asyncDir: string, status: AsyncStatus & { cwd?: string
282
323
  const summarizedSteps = steps.map((step, index) => {
283
324
  const stepActivityState = step.activityState;
284
325
  const stepLastActivityAt = step.lastActivityAt;
326
+ const timeoutRecovery = projectTimeoutRecovery(step.timeoutRecovery);
285
327
  return {
286
328
  index,
287
329
  childId: asyncStatusChildIdentity(step, index),
@@ -341,6 +383,7 @@ function statusToSummary(asyncDir: string, status: AsyncStatus & { cwd?: string
341
383
  ...(step.effects ? { effects: step.effects } : {}),
342
384
  ...(step.watchdog ? { watchdog: step.watchdog } : {}),
343
385
  ...(step.processTerminal ? { processTerminal: sanitizeProcessTerminal(step.processTerminal, { runId: status.runId, runnerProcessInstanceId: step.processTerminal.runnerProcessInstanceId }, `${path.join(asyncDir, "status.json")} step ${index}`) } : {}),
386
+ ...(timeoutRecovery ? { timeoutRecovery } : {}),
344
387
  ...(step.capabilityCeiling ? { capabilityCeiling: step.capabilityCeiling } : {}),
345
388
  ...(step.capabilityAudit ? { capabilityAudit: step.capabilityAudit } : {}),
346
389
  ...(step.children?.length ? { children: step.children } : {}),
@@ -381,6 +424,7 @@ function statusToSummary(asyncDir: string, status: AsyncStatus & { cwd?: string
381
424
  ...(status.pendingAppends !== undefined ? { pendingAppends: status.pendingAppends } : {}),
382
425
  ...(parallelGroups.length ? { parallelGroups } : {}),
383
426
  ...(hostSteps.length ? { hostSteps } : {}),
427
+ ...(status.mode === "workflow" && status.workflowGraph ? { workflowGraph: status.workflowGraph } : {}),
384
428
  steps: summarizedSteps,
385
429
  ...(nestedChildren.length ? { nestedChildren } : {}),
386
430
  ...(nestedWarnings.length ? { nestedWarnings } : {}),
@@ -491,10 +535,17 @@ export function listAsyncRuns(asyncDirRoot: string, options: AsyncRunListOptions
491
535
  };
492
536
  for (const entry of entries) {
493
537
  const asyncDir = path.join(asyncDirRoot, entry);
494
- const reconciliation = options.reconcile === false
495
- ? undefined
496
- : reconcileAsyncRun(asyncDir, { resultsDir: options.resultsDir, kill: options.kill, now: options.now });
497
- const status = (reconciliation?.status ?? readStatus(asyncDir)) as (AsyncStatus & { cwd?: string }) | null;
538
+ let status: (AsyncStatus & { cwd?: string }) | null;
539
+ try {
540
+ const reconciliation = options.reconcile === false
541
+ ? undefined
542
+ : reconcileAsyncRun(asyncDir, { resultsDir: options.resultsDir, kill: options.kill, now: options.now });
543
+ status = (reconciliation?.status ?? readStatus(asyncDir)) as (AsyncStatus & { cwd?: string }) | null;
544
+ } catch (error) {
545
+ if (!activeEntries.has(entry) || !isAsyncStatusIsolationError(asyncDir, error)) throw error;
546
+ isolateCorruptActiveRun(asyncDir, entry, error, options.now);
547
+ continue;
548
+ }
498
549
  if (!status) {
499
550
  if (activeEntries.has(entry)) updateActiveRunIndex(asyncDir, "failed");
500
551
  continue;
@@ -520,7 +571,14 @@ export function listAsyncRuns(asyncDirRoot: string, options: AsyncRunListOptions
520
571
  nestedWarnings.push(`Nested status unavailable: ${getErrorMessage(error)}`);
521
572
  }
522
573
  }
523
- const summary = statusToSummary(asyncDir, status, nestedWarnings, nestedRoute);
574
+ let summary: AsyncRunSummary;
575
+ try {
576
+ summary = statusToSummary(asyncDir, status, nestedWarnings, nestedRoute);
577
+ } catch (error) {
578
+ if (!activeEntries.has(entry) || !isAsyncStatusIsolationError(asyncDir, error)) throw error;
579
+ isolateCorruptActiveRun(asyncDir, entry, error, options.now);
580
+ continue;
581
+ }
524
582
  runs.push(summary);
525
583
  }
526
584
 
@@ -575,12 +633,41 @@ function formatHostStepLine(row: ReturnType<typeof projectAsyncWorkflowRows>[num
575
633
  return `host ${row.kind}: ${row.name} | ${state}${details.length ? ` | ${details.join(" | ")}` : ""}`;
576
634
  }
577
635
 
636
+ function workflowStageStateLabel(status: WorkflowGraphSnapshot["nodes"][number]["status"]): string {
637
+ switch (status) {
638
+ case "completed":
639
+ return "complete";
640
+ case "detached":
641
+ return "paused";
642
+ default:
643
+ return status;
644
+ }
645
+ }
646
+
647
+ export function formatWorkflowStageLine(node: WorkflowGraphSnapshot["nodes"][number], index: number, total: number): string {
648
+ const state = workflowStageStateLabel(node.status);
649
+ const id = previewDisplayText(node.id, 160);
650
+ const label = node.label ? previewDisplayText(node.label, 160) : "";
651
+ const display = label && label !== id ? ` | ${label}` : "";
652
+ const agent = node.agent ? ` | ${previewDisplayText(node.agent, 80)}` : "";
653
+ const error = node.error ? ` | ${previewDisplayText(node.error, 240)}` : "";
654
+ return `stage ${index + 1}/${total}: ${id}${display}${agent} | ${state}${error}`;
655
+ }
656
+
578
657
  export function formatAsyncRunOutputPath(run: Pick<AsyncRunSummary, "asyncDir" | "outputFile">): string | undefined {
579
658
  if (!run.outputFile) return undefined;
580
659
  return path.isAbsolute(run.outputFile) ? run.outputFile : path.join(run.asyncDir, run.outputFile);
581
660
  }
582
661
 
583
- export function formatAsyncRunProgressLabel(run: Pick<AsyncRunSummary, "mode" | "state" | "currentStep" | "chainStepCount" | "parallelGroups" | "steps">): string {
662
+ export function formatAsyncRunProgressLabel(run: Pick<AsyncRunSummary, "mode" | "state" | "currentStep" | "chainStepCount" | "parallelGroups" | "steps"> & { workflowGraph?: WorkflowGraphSnapshot }): string {
663
+ const graphStages = run.mode === "workflow" ? workflowGraphStageNodes(run.workflowGraph) : [];
664
+ if (graphStages.length > 0) {
665
+ const currentNode = graphStages.find((node) => node.id === run.workflowGraph?.currentNodeId);
666
+ if (currentNode && currentNode.status !== "completed") return `stage ${graphStages.indexOf(currentNode) + 1}/${graphStages.length}`;
667
+ const activeNode = graphStages.find((node) => node.status === "running") ?? graphStages.find((node) => node.status !== "completed");
668
+ if (activeNode) return `stage ${graphStages.indexOf(activeNode) + 1}/${graphStages.length}`;
669
+ return `stage ${graphStages.length}/${graphStages.length}`;
670
+ }
584
671
  const stepCount = run.steps.length || 1;
585
672
  const chainStepCount = run.chainStepCount ?? stepCount;
586
673
  const groups = normalizeParallelGroups(run.parallelGroups, run.steps.length, chainStepCount);
@@ -622,8 +709,14 @@ export function formatAsyncRunList(runs: AsyncRunSummary[], heading = "Active as
622
709
  if (preflightWarning) lines.push(preflightWarning);
623
710
  for (const step of run.steps) {
624
711
  lines.push(` ${formatStepLine(step)}`);
712
+ lines.push(...formatTimeoutRecoveryLines(step.timeoutRecovery, " "));
625
713
  lines.push(...formatNestedRunStatusLines(step.children, { indent: " ", maxLines: 12 }));
626
714
  }
715
+ const loadedWorkflowKeys = new Set(run.steps.flatMap((step) => step.workflowKey ? [step.workflowKey] : []));
716
+ const graphStages = run.mode === "workflow" ? workflowGraphStageNodes(run.workflowGraph) : [];
717
+ for (const [index, node] of graphStages.entries()) {
718
+ if (!loadedWorkflowKeys.has(node.id)) lines.push(` ${formatWorkflowStageLine(node, index, graphStages.length)}`);
719
+ }
627
720
  for (const row of projectAsyncWorkflowRows([], run.hostSteps)) {
628
721
  const line = formatHostStepLine(row);
629
722
  if (line) lines.push(` ${line}`);
@@ -62,7 +62,7 @@ export async function drainOutstandingWork(deps: AutoDrainDeps): Promise<void> {
62
62
  },
63
63
  );
64
64
  if (waitResult.isError) {
65
- throw new Error(`Auto-drain failed for session '${sessionId}': ${resultText(waitResult) || "subagent_wait returned an error without details"}.`);
65
+ throw new Error(`Auto-drain failed for session '${sessionId}': ${resultText(waitResult) || "bg_wait returned an error without details"}.`);
66
66
  }
67
67
  }
68
68
  }
@@ -1,8 +1,9 @@
1
1
  /**
2
2
  * Cross-OS control channel for async subagent runs.
3
3
  *
4
- * Background runs are detached OS processes. The original control path delivered
5
- * an interrupt with `process.kill(pid, SIGUSR2|SIGBREAK)`, but Windows cannot
4
+ * Background runs use child processes. Unix detaches them from the parent process.
5
+ * The original control path delivered an interrupt with
6
+ * `process.kill(pid, SIGUSR2|SIGBREAK)`, but Windows cannot
6
7
  * deliver those signals cross-process via `process.kill` and throws `ENOSYS`,
7
8
  * which left async runs uninterruptible (no stop, no live steer) on Windows.
8
9
  *
@@ -330,7 +330,7 @@ function formatDetachedForegroundFleetLines(runs: ForegroundRun[]): string[] {
330
330
  const childSummary = detachedChildren.map((child) => `${fleetChildDisplayName(child)} #${child.index}`).join(", ");
331
331
  lines.push(`- ${run.runId} | detached | ${run.mode}${childSummary ? ` | ${childSummary}` : ""}`);
332
332
  lines.push(` status: subagent({ action: "status", id: "${run.runId}" })`);
333
- lines.push(` recovery: reply to the supervisor request first, then wait with subagent_wait({ id: "${run.runId}" }); do not resume or launch a replacement while any child remains detached.`);
333
+ lines.push(` recovery: reply to the supervisor request first, then wait with bg_wait({ id: "${run.runId}" }); do not resume or launch a replacement while any child remains detached.`);
334
334
  }
335
335
  return lines;
336
336
  }
@@ -426,7 +426,7 @@ export function createResultWatcher(
426
426
  if (observerSucceeded) removeMissionObserverIndex(resultsDir, runId);
427
427
  const epoch = deliveryEpoch;
428
428
  if (!ownsCompletion(sessionId, completionOwnerId, epoch)) return;
429
- // Recorded before dedupe and before the unlink below so subagent_wait can
429
+ // Recorded before dedupe and before the unlink below so bg_wait can
430
430
  // use the in-memory record or its bounded durable replay after cleanup.
431
431
  recordWaitCompletion(state, runId, data, Date.now(), completionTtlMs, {
432
432
  resultsDir,
@@ -12,7 +12,7 @@ function isIntercomDetached(run: AsyncRunSummary): boolean {
12
12
 
13
13
  function formatIntercomDetachGuidance(run: AsyncRunSummary): string | undefined {
14
14
  if (!isIntercomDetached(run)) return undefined;
15
- return `Run "${run.id}" detached for intercom coordination. Reply to the supervisor request first, then wait with subagent_wait({ id: "${run.id}" }). Use subagent({ action: "status", id: "${run.id}" }) to recover the result; do not resume or launch a replacement while it remains detached.`;
15
+ return `Run "${run.id}" detached for intercom coordination. Reply to the supervisor request first, then wait with bg_wait({ id: "${run.id}" }). Use subagent({ action: "status", id: "${run.id}" }) to recover the result; do not resume or launch a replacement while it remains detached.`;
16
16
  }
17
17
 
18
18
  export function formatAsyncReviveCommand(run: AsyncRunSummary): string | undefined {
@@ -2,7 +2,7 @@ import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
3
  import type { AgentToolResult } from "@earendil-works/pi-agent-core";
4
4
  import { safeTerminalText } from "../../shared/display-text.ts";
5
- import { formatAsyncRunList, formatAsyncRunOutputPath, formatAsyncRunProgressLabel, listAsyncRuns } from "./async-status.ts";
5
+ import { formatAsyncRunList, formatAsyncRunOutputPath, formatAsyncRunProgressLabel, formatWorkflowStageLine, listAsyncRuns } from "./async-status.ts";
6
6
  import { formatAsyncResultTranscript, formatAsyncRunTranscript, formatNestedRunTranscript, inspectSubagentFleet } from "./fleet-view.ts";
7
7
  import { formatNestedRunStatusLines } from "../shared/nested-render.ts";
8
8
  import { formatModelThinking } from "../../shared/formatters.ts";
@@ -25,7 +25,9 @@ import { formatWorkflowJsonPreview } from "../../workflows/scripted-workflow.ts"
25
25
  import { parseWorkflowChildSummary } from "../../workflows/workflow-child-summary.ts";
26
26
  import { formatWorkflowPreflightPlanSummary, formatWorkflowPreflightWarningSummary } from "../../workflows/workflow-preflight.ts";
27
27
  import { formatRunFanoutBudget, getRunFanoutBudgetSnapshot, readRunFanoutBudgetDescriptor } from "../shared/run-fanout-budget.ts";
28
+ import { workflowGraphStageNodes } from "../shared/workflow-graph.ts";
28
29
  import { getExternalJobProvider } from "../../api/external-job-provider.ts";
30
+ import { formatTimeoutRecoveryLines } from "../shared/mutation-evidence.ts";
29
31
 
30
32
  interface RunStatusParams {
31
33
  action?: string;
@@ -126,7 +128,7 @@ function formatResumeGuidance(runId: string | undefined, children: Array<{ agent
126
128
  const resumableWorkflowChildren = workflowChildren.filter(({ child }) => !(child.status === "paused" && child.activityState === "needs_attention"));
127
129
  if (workflowChildren.length > 0) {
128
130
  return [
129
- ...supervisorDetachedWorkflowChildren.map(({ child }) => `Recovery workflow child${typeof child.workflowKey === "string" && child.workflowKey.trim() ? ` '${child.workflowKey}'` : ""}: reply to the supervisor request first, then wait with subagent_wait({ id: "${child.runId}" }). Use subagent({ action: "status", id: "${child.runId}" }) to recover the result; do not resume or launch a replacement while it remains detached.`),
131
+ ...supervisorDetachedWorkflowChildren.map(({ child }) => `Recovery workflow child${typeof child.workflowKey === "string" && child.workflowKey.trim() ? ` '${child.workflowKey}'` : ""}: reply to the supervisor request first, then wait with bg_wait({ id: "${child.runId}" }). Use subagent({ action: "status", id: "${child.runId}" }) to recover the result; do not resume or launch a replacement while it remains detached.`),
130
132
  ...resumableWorkflowChildren.map(({ child }) => `Revive workflow child${typeof child.workflowKey === "string" && child.workflowKey.trim() ? ` '${child.workflowKey}'` : ""}: subagent({ action: "resume", id: "${child.runId}", message: "..." })`),
131
133
  ].join("\n");
132
134
  }
@@ -226,7 +228,7 @@ function formatRememberedForegroundStatus(run: ForegroundResumeRun): string {
226
228
  const detached = run.children.some((child) => child.status === "detached");
227
229
  const resumable = run.children.find((child) => hasExistingSessionFile(child.sessionFile));
228
230
  if (detached) {
229
- lines.push(`Recovery: reply to the supervisor request first, then wait with subagent_wait({ id: "${run.runId}" }); do not resume or launch a replacement while any child remains detached.`);
231
+ lines.push(`Recovery: reply to the supervisor request first, then wait with bg_wait({ id: "${run.runId}" }); do not resume or launch a replacement while any child remains detached.`);
230
232
  } else if (resumable) {
231
233
  lines.push(run.children.length === 1
232
234
  ? `Revive: subagent({ action: "resume", id: "${run.runId}", message: "..." })`
@@ -481,6 +483,7 @@ export function inspectSubagentStatus(params: RunStatusParams, deps: RunStatusDe
481
483
  currentStep: status.currentStep,
482
484
  chainStepCount: status.chainStepCount,
483
485
  parallelGroups: status.parallelGroups,
486
+ workflowGraph: status.workflowGraph,
484
487
  steps: (status.steps ?? []).map((step, index) => ({ index, agent: step.agent, status: step.status })),
485
488
  });
486
489
  const started = new Date(status.startedAt).toISOString();
@@ -548,6 +551,7 @@ export function inspectSubagentStatus(params: RunStatusParams, deps: RunStatusDe
548
551
  const display = runStatusStepDisplayName(step);
549
552
  const phase = step.phase ? `[${step.phase}] ` : "";
550
553
  lines.push(`${stepLineLabel(status, index)}: ${phase}${display} ${step.status}${modelText}${stepActivityText ? `, ${stepActivityText}` : ""}${steeringSuffix}${acceptanceText}${budgetText}${errorText}`);
554
+ lines.push(...formatTimeoutRecoveryLines(step.timeoutRecovery, " "));
551
555
  if (step.runner?.type === "external-cli") {
552
556
  const runner = normalizeExternalCliRunnerStatus(step.runner);
553
557
  if (runner) {
@@ -593,6 +597,11 @@ export function inspectSubagentStatus(params: RunStatusParams, deps: RunStatusDe
593
597
  lines.push(" Steer: unavailable; external runners do not accept live messages.");
594
598
  }
595
599
  }
600
+ const loadedWorkflowKeys = new Set((status.steps ?? []).flatMap((step) => step.workflowKey ? [step.workflowKey] : []));
601
+ const graphStages = status.mode === "workflow" ? workflowGraphStageNodes(status.workflowGraph) : [];
602
+ for (const [index, node] of graphStages.entries()) {
603
+ if (!loadedWorkflowKeys.has(node.id)) lines.push(` ${formatWorkflowStageLine(node, index, graphStages.length)}`);
604
+ }
596
605
  const attached = new Set((status.steps ?? []).flatMap((step) => step.children?.map((child) => child.id) ?? []));
597
606
  const unattached = nestedChildren.filter((child) => !attached.has(child.id));
598
607
  lines.push(...formatNestedRunStatusLines(unattached, { indent: "", commandHints: true, maxLines: 20 }));
@@ -632,7 +641,7 @@ export function inspectSubagentStatus(params: RunStatusParams, deps: RunStatusDe
632
641
  }
633
642
  try {
634
643
  const raw = fs.readFileSync(resultPath, "utf-8");
635
- const data = JSON.parse(raw) as { id?: string; runId?: string; toolCallId?: string; agent?: string; success?: boolean; summary?: string; output?: string; exitCode?: number; state?: string; stopped?: boolean; timedOut?: boolean; turnBudgetExceeded?: boolean; processSignal?: string | null; sessionFile?: string; parallelHandoff?: { path?: string }; results?: Array<{ agent?: string; sessionName?: string; runId?: string; workflowKey?: string; output?: string; summary?: string; sessionFile?: string; state?: string; success?: boolean; exitCode?: number | null; stopped?: boolean; timedOut?: boolean; turnBudgetExceeded?: boolean; interrupted?: boolean; processSignal?: string | null }> };
644
+ const data = JSON.parse(raw) as { id?: string; runId?: string; toolCallId?: string; agent?: string; success?: boolean; summary?: string; output?: string; exitCode?: number; state?: string; stopped?: boolean; timedOut?: boolean; turnBudgetExceeded?: boolean; processSignal?: string | null; sessionFile?: string; timeoutRecovery?: unknown; parallelHandoff?: { path?: string }; results?: Array<{ agent?: string; sessionName?: string; runId?: string; workflowKey?: string; output?: string; summary?: string; sessionFile?: string; state?: string; success?: boolean; exitCode?: number | null; stopped?: boolean; timedOut?: boolean; turnBudgetExceeded?: boolean; interrupted?: boolean; processSignal?: string | null; timeoutRecovery?: unknown }> };
636
645
  if (params.view === "transcript") {
637
646
  try {
638
647
  return { content: [{ type: "text", text: formatAsyncResultTranscript(data, resultPath, { index: params.index, lines: params.lines }) }], details: { mode: "single", results: [] } };
@@ -660,6 +669,8 @@ export function inspectSubagentStatus(params: RunStatusParams, deps: RunStatusDe
660
669
  const lines = [`Run: ${runId}`, data.toolCallId ? `Tool call: ${data.toolCallId}` : undefined, `State: ${status}`, `Result: ${resultPath}`].filter((line): line is string => Boolean(line));
661
670
  if (data.parallelHandoff?.path) lines.push(`Parallel handoff: ${data.parallelHandoff.path}`);
662
671
  const children = Array.isArray(data.results) ? data.results : data.agent ? [{ agent: data.agent, sessionFile: data.sessionFile }] : [];
672
+ lines.push(...formatTimeoutRecoveryLines(data.timeoutRecovery, " "));
673
+ for (const child of children) lines.push(...formatTimeoutRecoveryLines(child.timeoutRecovery, " "));
663
674
  lines.push(formatResumeGuidance(runId, children, data.sessionFile, { stopped: status === "stopped" }));
664
675
  if (data.summary) lines.push("", data.summary);
665
676
  const workflowChildren = parseWorkflowChildSummary((data as unknown as Record<string, unknown>).workflowChildren);
@@ -75,7 +75,7 @@ import {
75
75
  DEFAULT_GLOBAL_CONCURRENCY_LIMIT,
76
76
  Semaphore,
77
77
  } from "../shared/parallel-utils.ts";
78
- import { applyThinkingSuffix, buildPiArgs, cleanupTempDir, projectLaunchResolvedChildExtensions, resolvePiLaunchToolPlan, type SubagentTaskDelivery } from "../shared/pi-args.ts";
78
+ import { applyThinkingSuffix, buildPiArgs, cleanupTempDir, deriveForkPromptCacheKey, projectLaunchResolvedChildExtensions, resolvePiLaunchToolPlan, type SubagentTaskDelivery } from "../shared/pi-args.ts";
79
79
  import { deriveChildSessionName } from "../../shared/child-session-name.ts";
80
80
  import { readRuntimeAcknowledgedExtensions } from "../shared/runtime-acknowledged-extensions.ts";
81
81
  import { outputEntryFromAsyncResult, resolveOutputReferences } from "../shared/chain-outputs.ts";
@@ -1640,6 +1640,7 @@ async function runSingleStepInner(
1640
1640
  const writerProcesses: PiWriterProcessInstanceExitV1[] = [];
1641
1641
  let writerAttemptCount = 0;
1642
1642
  const attemptNotes: string[] = [];
1643
+ let finalRequiredOutputMissing: boolean | undefined;
1643
1644
  const eventsPath = path.join(path.dirname(ctx.outputFile), "events.jsonl");
1644
1645
  let finalResult: RunPiStreamingResult | undefined;
1645
1646
  let finalOutputSnapshot: SingleOutputSnapshot | undefined;
@@ -1698,6 +1699,7 @@ async function runSingleStepInner(
1698
1699
  : undefined;
1699
1700
  const { args, env, tempDir, toolDiagnosticPath, runtimeAcknowledgedExtensionsPath, capabilityAudit: attemptCapabilityAudit, warnings } = buildPiArgs(omitUndefinedProperties({
1700
1701
  parentSessionId: step.parentSessionId,
1702
+ forkCacheKey: step.context === "fork" ? deriveForkPromptCacheKey(step.parentSessionId) : undefined,
1701
1703
  baseArgs: ["--mode", "json", "-p"],
1702
1704
  task: attemptTask,
1703
1705
  taskDelivery: taskDeliveryOverride,
@@ -1929,6 +1931,7 @@ async function runSingleStepInner(
1929
1931
  : effectiveStructuredOutput
1930
1932
  ? { kind: "structured" as const, path: effectiveStructuredOutput.outputPath, missing: !fs.existsSync(effectiveStructuredOutput.outputPath) }
1931
1933
  : undefined;
1934
+ finalRequiredOutputMissing = requiredOutput?.missing;
1932
1935
  const missingRequiredOutputError = formatRequiredOutputError(requiredOutput);
1933
1936
  const missingRequiredOutputAfterMutation = Boolean(missingRequiredOutputError) && (mutationAttemptObserved || Boolean(mutationEvidence.changedFiles.length));
1934
1937
  const effectiveExitCode = toolAvailabilityError || completionEvidence.legacyFailureError || midToolExitError || structuredError || emptyOutputError || missingRequiredOutputError
@@ -2117,6 +2120,7 @@ async function runSingleStepInner(
2117
2120
  ? buildTimeoutRecoverySummary({
2118
2121
  termination: "timed-out",
2119
2122
  evidence: finalMutationEvidence,
2123
+ requiredOutputMissing: finalRequiredOutputMissing,
2120
2124
  currentTool: finalResult?.currentTool,
2121
2125
  currentToolArgs: finalResult?.currentToolArgs,
2122
2126
  currentPath: finalResult?.currentPath,
@@ -2145,7 +2149,7 @@ async function runSingleStepInner(
2145
2149
  report: structuredAcceptanceReport as import("../../shared/types.ts").AcceptanceReport | undefined,
2146
2150
  reportError: structuredAcceptanceReportError,
2147
2151
  fileOutput: childWrittenOutput !== undefined && step.outputPath
2148
- ? { content: childWrittenOutput, path: step.outputPath, authoritative: step.outputMode === "file-only" }
2152
+ ? { content: childWrittenOutput, path: step.outputPath, authoritative: step.outputMode === "file-only", durable: resolvedOutput.savedPath !== undefined }
2149
2153
  : undefined,
2150
2154
  cwd: step.cwd ?? ctx.cwd,
2151
2155
  signal: combinedAbortSignal([ctx.timeoutSignal, ctx.stopSignal]),
@@ -5343,7 +5347,6 @@ async function runSubagent(
5343
5347
  statusPayload.error = `Step failed: ${failedStep.agent}`;
5344
5348
  }
5345
5349
  }
5346
- writeStatusPayload();
5347
5350
  try {
5348
5351
  runPersistence.write(resultPath, {
5349
5352
  lifecycleArtifactVersion: SUBAGENT_LIFECYCLE_ARTIFACT_VERSION,
@@ -1,5 +1,5 @@
1
1
  /**
2
- * `subagent_wait` tool: block the current turn until outstanding async runs
2
+ * `bg_wait` tool: block the current turn until outstanding async runs
3
3
  * or a named remembered detached foreground run finishes.
4
4
  *
5
5
  * Background subagent runs are detached. In an interactive session the parent
@@ -8,33 +8,33 @@
8
8
  * cannot work at all non-interactively (`pi -p ...`), where the run is a single
9
9
  * turn: once the turn ends there is nothing left to receive the notification.
10
10
  *
11
- * `subagent_wait` closes that gap. It keeps the turn alive until a tracked async
11
+ * `bg_wait` closes that gap. It keeps the turn alive until a tracked async
12
12
  * run for this session reaches a terminal state (complete / failed / paused),
13
13
  * the caller-supplied timeout elapses, or the turn is aborted. Because it awaits
14
14
  * inside the turn, the completion the model was told to wait for is actually
15
15
  * observed before the tool returns.
16
16
  *
17
- * By default `subagent_wait` returns as soon as ONE run finishes, so a fleet
17
+ * By default `bg_wait` returns as soon as ONE run finishes, so a fleet
18
18
  * manager can use it in a rolling-replacement loop: launch N workers, wait for
19
- * the next one to finish, spawn its replacement, then call `subagent_wait`
19
+ * the next one to finish, spawn its replacement, then call `bg_wait`
20
20
  * again — keeping N in flight instead of draining to zero between batches.
21
21
  * Pass `all: true` to block until every tracked async run is terminal, or `id`
22
22
  * to block on one specific async or remembered detached foreground run.
23
23
  *
24
- * `subagent_wait` also returns when a run needs attention — not just on
24
+ * `bg_wait` also returns when a run needs attention — not just on
25
25
  * completion. A child that goes idle or blocks for a decision surfaces
26
26
  * `needs_attention` (the same signal Pi shows as a control notice and,
27
- * interactively, wakes the parent with). Since `subagent_wait` is used exactly
27
+ * interactively, wakes the parent with). Since `bg_wait` is used exactly
28
28
  * where there is no next turn to receive that notice, it must break on it too,
29
29
  * or a stuck child would stall the loop until the timeout. Attention runs are
30
30
  * reported so the caller can inspect / nudge / resume / interrupt them.
31
31
  *
32
- * Wake mechanism: when given Pi's event bus (`deps.events`), `subagent_wait`
32
+ * Wake mechanism: when given Pi's event bus (`deps.events`), `bg_wait`
33
33
  * subscribes to the subagent completion/control channels and wakes the instant
34
34
  * any fires, rather than waiting out a fixed poll interval. A poll still runs
35
35
  * on the interval as a reconciliation fallback (crashed runners, missed
36
36
  * events), and the poll is the source of truth for what actually changed — the
37
- * event only ends the sleep early. With no bus, `subagent_wait` degrades to pure
37
+ * event only ends the sleep early. With no bus, `bg_wait` degrades to pure
38
38
  * polling.
39
39
  */
40
40
 
@@ -65,6 +65,7 @@ import { formatDuration, shortenPath } from "../../shared/formatters.ts";
65
65
  import { toAgentToolUsage } from "../../shared/utils.ts";
66
66
  import { collectWaitCompletions } from "./wait-completions.ts";
67
67
  import { formatResumeFirstFailedRunsNote } from "./resume-guidance.ts";
68
+ import { formatTimeoutRecoveryLines } from "../shared/mutation-evidence.ts";
68
69
  export { WAIT_TOOL_DEFAULT_TIMEOUT_MS_ENV, WAIT_TOOL_ENABLED_ENV, resolveWaitToolConfig, type ResolvedWaitToolConfig } from "./wait-config.ts";
69
70
 
70
71
  /** States that mean a run is still in flight (not yet resolved). */
@@ -81,9 +82,8 @@ export interface SubagentWaitParams {
81
82
  nonBlocking?: boolean;
82
83
  /**
83
84
  * When true, block until EVERY active run in this session (or matching `id`)
84
- * is terminal. Default false: return as soon as the first run finishes, so a
85
- * fleet manager can spawn a replacement and wait again. Ignored when `id`
86
- * targets a single run.
85
+ * is terminal. Default false: return when the first tracked run or provider
86
+ * item finishes or needs attention. Ignored when `id` targets a single run.
87
87
  */
88
88
  all?: boolean;
89
89
  /** Give up after this many milliseconds. Defaults to waitTool.defaultTimeoutMs, then 30 minutes. */
@@ -240,7 +240,7 @@ function foregroundChildrenNeedingAttention(run: ForegroundResumeRun, indices: S
240
240
  function formatForegroundAttention(run: ForegroundResumeRun, children: ReturnType<typeof foregroundChildrenNeedingAttention>, elapsedMs: number): AgentToolResult<Details> {
241
241
  const childList = children.map((child) => `${child.agent}${child.index !== undefined ? `#${child.index}` : ""}`).join(", ");
242
242
  return result(
243
- `Waited ${formatDuration(elapsedMs)} for remembered detached foreground run "${run.runId}"; attention required. ${children.length} child run(s) need attention: ${childList}. Reply to any pending supervisor request, then call subagent_wait({ id: "${run.runId}" }) again or inspect status; do not resume or launch a replacement while it remains detached.`,
243
+ `Waited ${formatDuration(elapsedMs)} for remembered detached foreground run "${run.runId}"; attention required. ${children.length} child run(s) need attention: ${childList}. Reply to any pending supervisor request, then call bg_wait({ id: "${run.runId}" }) again or inspect status; do not resume or launch a replacement while it remains detached.`,
244
244
  );
245
245
  }
246
246
 
@@ -261,7 +261,7 @@ function backgroundWorkIdentity(item: RegisteredBackgroundWorkItem): string {
261
261
 
262
262
  function backgroundWorkForSession(deps: SubagentWaitDeps, nowMs: number): BackgroundWorkSnapshot {
263
263
  const sessionId = deps.state.currentSessionId;
264
- if (!sessionId) throw new Error("subagent_wait requires an active session identity to scope background work safely.");
264
+ if (!sessionId) throw new Error("bg_wait requires an active session identity to scope background work safely.");
265
265
  return deps.backgroundWork?.snapshot(sessionId, nowMs) ?? snapshotBackgroundWork(sessionId, nowMs);
266
266
  }
267
267
 
@@ -349,6 +349,12 @@ function result(text: string, isError = false, completions?: WaitCompletion[]):
349
349
  };
350
350
  }
351
351
 
352
+ function formatCompletionRecovery(completions: WaitCompletion[] | undefined): string {
353
+ const lines = (completions ?? []).flatMap((completion) =>
354
+ (completion.results ?? []).flatMap((child) => formatTimeoutRecoveryLines(child.timeoutRecovery)));
355
+ return lines.length > 0 ? `\n${lines.join("\n")}` : "";
356
+ }
357
+
352
358
  function windowElapsedResult(
353
359
  text: string,
354
360
  activeRunIds: string[],
@@ -369,7 +375,7 @@ function windowElapsedResult(
369
375
  };
370
376
  }
371
377
 
372
- /** Build the live status shown while async work keeps subagent_wait blocked. */
378
+ /** Build the live status shown while async work keeps bg_wait blocked. */
373
379
  function asyncWaitUpdate(runs: AsyncRunSummary[], providerCount: number, elapsedMs: number): AgentToolResult<Details> {
374
380
  const activity = runs.flatMap((run) => {
375
381
  const activeSteps = run.steps.filter((step) => step.status === "pending" || step.status === "running");
@@ -517,7 +523,7 @@ async function waitForDetachedForegroundRun(
517
523
  }
518
524
  if (now() - startedAt >= timeoutMs) {
519
525
  return windowElapsedResult(
520
- `Wait window elapsed after ${formatDuration(timeoutMs)} with remembered foreground run "${run.runId}" still detached. Reply to any pending supervisor request, then call subagent_wait({ id: "${run.runId}" }) again or inspect status; do not resume or launch a replacement while it remains detached.`,
526
+ `Wait window elapsed after ${formatDuration(timeoutMs)} with remembered foreground run "${run.runId}" still detached. Reply to any pending supervisor request, then call bg_wait({ id: "${run.runId}" }) again or inspect status; do not resume or launch a replacement while it remains detached.`,
521
527
  [run.runId],
522
528
  );
523
529
  }
@@ -536,10 +542,10 @@ export async function waitForSubagents(
536
542
  deps: SubagentWaitDeps,
537
543
  ): Promise<AgentToolResult<Details>> {
538
544
  if (deps.enabled === false) {
539
- return result("subagent_wait is disabled by config.waitTool or PI_SUBAGENT_WAIT_TOOL_ENABLED; returning immediately without blocking background work. Active work keeps going, and you can inspect subagents with subagent({ action: \"status\" }) or rely on completion notifications.");
545
+ return result("bg_wait is disabled by config.waitTool or PI_SUBAGENT_WAIT_TOOL_ENABLED; returning immediately without blocking background work. Active work keeps going, and you can inspect subagents with subagent({ action: \"status\" }) or rely on completion notifications.");
540
546
  }
541
547
  if (!deps.state.currentSessionId) {
542
- return result("subagent_wait requires an active session identity to scope background work safely.", true);
548
+ return result("bg_wait requires an active session identity to scope background work safely.", true);
543
549
  }
544
550
 
545
551
  const now = deps.now ?? Date.now;
@@ -580,7 +586,7 @@ export async function waitForSubagents(
580
586
  const selected = matches[0];
581
587
  if (selected && params.nonBlocking) {
582
588
  if (!deps.subscribe) {
583
- return result("Non-blocking wait subscriptions require a long-lived interactive subagent runtime; this runtime can only use blocking subagent_wait calls.", true);
589
+ return result("Non-blocking wait subscriptions require a long-lived interactive subagent runtime; this runtime can only use blocking bg_wait calls.", true);
584
590
  }
585
591
  try {
586
592
  const registration = deps.subscribe({ targetKind: selected.kind, runId: selected.id, requestedId: params.id, timeoutMs });
@@ -634,7 +640,7 @@ export async function waitForSubagents(
634
640
  }
635
641
  if (now() - startedAt >= timeoutMs) {
636
642
  return windowElapsedResult(
637
- `Wait window elapsed after ${formatDuration(timeoutMs)} with ${activeInitialRuns.length} async run(s) and ${activeInitialProviderItems.length} provider item(s) still active: ${stillActive}. The work keeps going; call subagent_wait again or inspect subagent status.`,
643
+ `Wait window elapsed after ${formatDuration(timeoutMs)} with ${activeInitialRuns.length} async run(s) and ${activeInitialProviderItems.length} provider item(s) still active: ${stillActive}. The work keeps going; call bg_wait again or inspect subagent status.`,
638
644
  activeInitialRuns.map((run) => run.id),
639
645
  activeInitialProviderItems,
640
646
  );
@@ -646,7 +652,7 @@ export async function waitForSubagents(
646
652
  providerSnapshot = params.id ? providerSnapshot : backgroundWorkForSession(deps, now());
647
653
  for (const provider of initialProviderNames) {
648
654
  if (!providerSnapshot.providers.includes(provider)) {
649
- return result(`Background-work provider '${provider}' disappeared while subagent_wait was tracking its active work; completion cannot be confirmed.`, true);
655
+ return result(`Background-work provider '${provider}' disappeared while bg_wait was tracking its active work; completion cannot be confirmed.`, true);
650
656
  }
651
657
  }
652
658
  providerActive = providerSnapshot.items;
@@ -685,6 +691,7 @@ export async function waitForSubagents(
685
691
  + providerActive.filter((item) => initialProviderIds.has(backgroundWorkIdentity(item))).length;
686
692
  const elapsed = formatDuration(now() - startedAt);
687
693
  const outcome = terminalSummary ? ` Outcome: ${terminalSummary}.` : "";
694
+ const recoveryNote = formatCompletionRecovery(completions);
688
695
 
689
696
  if (waitForAll) {
690
697
  const scope = params.id
@@ -694,7 +701,7 @@ export async function waitForSubagents(
694
701
  : `${initialAsyncIds.size} async run(s) and ${initialProviderIds.size} provider item(s)`;
695
702
  const status = relevantAttention.length > 0 ? "attention required" : "done";
696
703
  return result(
697
- `Waited ${elapsed} for ${scope}; ${status}.${outcome}${resumeGuidance}${attentionNote} Completion/control events have been observed; inspect status if a notification is not visible yet.`,
704
+ `Waited ${elapsed} for ${scope}; ${status}.${outcome}${recoveryNote}${resumeGuidance}${attentionNote} Completion/control events have been observed; inspect status if a notification is not visible yet.`,
698
705
  (deps.failOnFailedRuns === true && failedAsyncCount > 0) || (deps.failOnAttention === true && relevantAttention.length > 0),
699
706
  completions,
700
707
  );
@@ -703,7 +710,7 @@ export async function waitForSubagents(
703
710
  const finishedCount = finishedAsyncCount + providerFinishedCount;
704
711
  const subject = initialProviderIds.size === 0 ? "run(s)" : "item(s)";
705
712
  const remainder = stillRunning > 0
706
- ? ` ${stillRunning} ${subject} still in flight — call subagent_wait again to catch the next one.`
713
+ ? ` ${stillRunning} ${subject} still in flight — call bg_wait again to catch the next one.`
707
714
  : relevantAttention.length > 0
708
715
  ? " No other work is waitable until attention is handled."
709
716
  : initialProviderIds.size === 0 ? " No runs remain in flight." : " No work remains in flight.";
@@ -711,7 +718,7 @@ export async function waitForSubagents(
711
718
  ? `${relevantAttention.length} of ${initialCount} ${subject} need attention`
712
719
  : `${finishedCount} of ${initialCount} ${subject} finished`;
713
720
  return result(
714
- `Waited ${elapsed}; ${progress}.${outcome}${resumeGuidance}${attentionNote}${remainder} Relevant completion/control events have been observed; inspect status if a notification is not visible yet.`,
721
+ `Waited ${elapsed}; ${progress}.${outcome}${recoveryNote}${resumeGuidance}${attentionNote}${remainder} Relevant completion/control events have been observed; inspect status if a notification is not visible yet.`,
715
722
  (deps.failOnFailedRuns === true && failedAsyncCount > 0) || (deps.failOnAttention === true && relevantAttention.length > 0),
716
723
  completions,
717
724
  );
@@ -4,6 +4,7 @@ import type { AsyncRunSummary } from "./async-status.ts";
4
4
  import { readCompletionReplay, writeCompletionReplay } from "./completion-replay.ts";
5
5
  import { fallbackResultPayloadPathForSessionRun, resultFilePath, resultPayloadPathForSessionRun } from "./result-files.ts";
6
6
  import { parseWorkflowChildSummary } from "../../workflows/workflow-child-summary.ts";
7
+ import { projectTimeoutRecovery } from "../shared/mutation-evidence.ts";
7
8
 
8
9
  function asNonEmptyString(value: unknown): string | undefined {
9
10
  return typeof value === "string" && value ? value : undefined;
@@ -65,6 +66,7 @@ export function toWaitCompletion(data: Record<string, unknown>, runId: string):
65
66
  const error = asNonEmptyString(child.error);
66
67
  const model = asNonEmptyString(child.model);
67
68
  const contextOverflow = child.contextOverflow === true;
69
+ const timeoutRecovery = projectTimeoutRecovery(child.timeoutRecovery);
68
70
  return [{
69
71
  ...(agent ? { agent } : {}),
70
72
  ...(childRunId ? { runId: childRunId } : {}),
@@ -76,6 +78,7 @@ export function toWaitCompletion(data: Record<string, unknown>, runId: string):
76
78
  ...(model ? { model } : {}),
77
79
  ...(contextOverflow ? { contextOverflow: true } : {}),
78
80
  ...(artifactPaths ? { artifactPaths } : {}),
81
+ ...(timeoutRecovery ? { timeoutRecovery } : {}),
79
82
  }];
80
83
  })
81
84
  : undefined;
@@ -96,7 +99,7 @@ export function toWaitCompletion(data: Record<string, unknown>, runId: string):
96
99
  }
97
100
 
98
101
  /**
99
- * Record a consumed terminal payload for later surfacing by subagent_wait, pruning
102
+ * Record a consumed terminal payload for later surfacing by bg_wait, pruning
100
103
  * stale entries with the same TTL that dedupes completion notifications. The result
101
104
  * file is deleted after delivery, so this record is the only in-process source once
102
105
  * the watcher has consumed it.