pi-subagents 0.60.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 (57) hide show
  1. package/CHANGELOG.md +32 -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 +12 -3
  8. package/docs/workflows.md +14 -13
  9. package/install.mjs +2 -1
  10. package/package.json +1 -1
  11. package/skills/pi-subagents/SKILL.md +6 -4
  12. package/skills/pi-subagents/references/constraints-and-recipes.md +1 -1
  13. package/skills/pi-subagents/references/execution-controls.md +42 -11
  14. package/skills/pi-subagents/references/multi-lane-orchestration.md +1 -1
  15. package/skills/pi-subagents/references/prompting-and-roles.md +4 -4
  16. package/skills/pi-subagents/references/review-and-validation.md +1 -1
  17. package/src/agents/agent-management.ts +102 -63
  18. package/src/agents/agents.ts +527 -221
  19. package/src/api/background-work.ts +7 -2
  20. package/src/api/external-runs.ts +67 -4
  21. package/src/api/preflight.ts +13 -8
  22. package/src/api/shared-types.ts +1 -0
  23. package/src/extension/index.ts +7 -4
  24. package/src/extension/public-execution.ts +47 -4
  25. package/src/extension/rpc.ts +62 -4
  26. package/src/extension/schemas.ts +11 -7
  27. package/src/extension/tool-description.ts +14 -18
  28. package/src/runs/background/async-execution.ts +51 -35
  29. package/src/runs/background/async-job-tracker.ts +62 -3
  30. package/src/runs/background/async-resume.ts +3 -1
  31. package/src/runs/background/async-status.ts +59 -9
  32. package/src/runs/background/auto-drain.ts +1 -1
  33. package/src/runs/background/fleet-view.ts +1 -1
  34. package/src/runs/background/result-watcher.ts +1 -1
  35. package/src/runs/background/resume-guidance.ts +1 -1
  36. package/src/runs/background/run-status.ts +2 -2
  37. package/src/runs/background/subagent-runner.ts +1 -2
  38. package/src/runs/background/subagent-wait.ts +20 -21
  39. package/src/runs/background/wait-completions.ts +1 -1
  40. package/src/runs/background/wait-tool.ts +24 -18
  41. package/src/runs/foreground/execution.ts +61 -2
  42. package/src/runs/foreground/subagent-executor.ts +178 -58
  43. package/src/runs/shared/acceptance.ts +43 -18
  44. package/src/runs/shared/host-step-status.ts +1 -0
  45. package/src/runs/shared/model-fallback.ts +61 -17
  46. package/src/runs/shared/permissions.ts +1 -1
  47. package/src/runs/shared/tool-timeout.ts +1 -1
  48. package/src/runs/shared/workflow-graph.ts +3 -2
  49. package/src/shared/types.ts +40 -3
  50. package/src/shared/workflow-child-permit.ts +91 -0
  51. package/src/slash/prompt-template-bridge.ts +37 -1
  52. package/src/slash/slash-commands.ts +18 -26
  53. package/src/tui/render.ts +95 -52
  54. package/src/workflows/scripted-workflow.ts +153 -4
  55. package/src/workflows/workflow-child-summary.ts +1 -1
  56. package/src/workflows/workflow-receipt.ts +41 -4
  57. package/src/workflows/workflow-resources.ts +150 -0
@@ -26,7 +26,7 @@ import { backgroundProcessOptions } from "../shared/background-process-options.t
26
26
  import { buildSkillInjection, normalizeSkillInput, resolveSkillsWithFallback } from "../../agents/skills.ts";
27
27
  import { buildAgentMemoryInjection } from "../../agents/agent-memory.ts";
28
28
  import { PI_CODING_AGENT_PACKAGE_ROOT_ENV, PROMPT_REDACTED, resolveChildCwd } from "../../shared/utils.ts";
29
- import { buildModelCandidates, inheritsParentModel, resolveEffectiveSubagentModel, resolveSubagentModelOverride, type AvailableModelInfo, type ParentModel } from "../shared/model-fallback.ts";
29
+ import { buildModelCandidates, resolveEffectiveSubagentModel, resolveModelOrigin, resolveSubagentModelOverride, type AvailableModelInfo, type ModelOrigin, type ParentModel } from "../shared/model-fallback.ts";
30
30
  import { resolveToolTimeoutMs, toolTimeoutFromEnv } from "../shared/tool-timeout.ts";
31
31
  import { resolveModelScopesForAgent, type ModelScopeConfig } from "../shared/model-scope.ts";
32
32
  import { findModelInfo, resolveEffectiveThinking } from "../../shared/model-info.ts";
@@ -235,6 +235,7 @@ interface AsyncSingleParams {
235
235
  structuredOutputSchema?: JsonSchemaObject;
236
236
  modelOverride?: string;
237
237
  modelOverrideFromParent?: boolean;
238
+ modelOrigin?: ModelOrigin;
238
239
  fast?: boolean;
239
240
  thinkingOverride?: AgentConfig["thinking"];
240
241
  availableModels?: AvailableModelInfo[];
@@ -339,15 +340,15 @@ export function formatAsyncStartedMessage(headline: string, interactive: boolean
339
340
  const guidance = interactive
340
341
  ? [
341
342
  "The async run is detached and running in the background.",
342
- "You are in an interactive session. By default, return control to the user now; Pi will wake you on completion when the run finishes or needs attention. Do NOT call subagent_wait() merely to wait, and do not run sleep/polling loops to wait for it.",
343
- "When you need an explicit wake for one known run but do not need same-turn results, call subagent_wait({ id: \"...\", nonBlocking: true }) to arm a subscription and return immediately.",
344
- "Override the default and call blocking subagent_wait() before ending the turn only when the current request is run-to-completion — for example, the user asked you to report results back here before continuing, or a skill must finish in one turn. In that case, call subagent_wait() to block until the run completes so its results are delivered in this turn instead of deferred.",
343
+ "You are in an interactive session. Return control to the user now; Pi will wake you through the native completion notification when this subagent completes or needs attention. Do not run sleep/polling loops to wait for this async subagent; it does not need a wait call.",
344
+ "Use bg_wait only for provider, detached, or other background work that lacks a native completion notification.",
345
+ "If the current turn must receive results from work without a native notification before it ends, call blocking bg_wait(); ordinary async subagent runs do not need a wait call because their completion is delivered natively.",
345
346
  "Otherwise, continue any independent work or return control to the user. Use subagent({ action: \"status\", id: \"...\" }) for a one-shot status/result or to inspect a blocked/stale run, never as a wait loop.",
346
347
  ]
347
348
  : [
348
349
  "The async run is detached. Do not run sleep timers or polling loops just to wait for it.",
349
- "This is a non-interactive run: Pi auto-drains current-session background work at agent_end so detached children are not abandoned; call subagent_wait() when this turn must receive the run's results before it ends, otherwise let the headless auto-drain finish the work.",
350
- "Use subagent({ action: \"status\", id: \"...\" }) when you need a one-shot status/result or to inspect a blocked/stale run. To block until completion, use subagent_wait() — do not poll in a loop.",
350
+ "This is a non-interactive run: Pi auto-drains current-session subagent work at agent_end so detached children are not abandoned. Use bg_wait only when this turn must receive provider, detached, or other background-work results that have no native completion notification.",
351
+ "Use subagent({ action: \"status\", id: \"...\" }) when you need a one-shot status/result or to inspect a blocked/stale run; do not poll in a loop.",
351
352
  ];
352
353
  return [headline, "", ...guidance].join("\n");
353
354
  }
@@ -857,14 +858,15 @@ export function buildAsyncRunnerSteps(id: string, params: AsyncRunnerStepBuildPa
857
858
  const task = namespaceOutputPath ? taskText : injectSingleOutputInstruction(taskText, outputPath, a);
858
859
 
859
860
  const modelScopes = resolveModelScopesForAgent(ctx.modelScope, a.name, ctx.currentModel);
860
- const primaryModelFromParent = inheritsParentModel(s.model, a.model, ctx.currentModel);
861
+ const modelOrigin = resolveModelOrigin({ explicitModel: s.model, agentModel: a.model, parentModel: ctx.currentModel });
862
+ const primaryModelFromParent = modelOrigin === "inherited";
861
863
  const primaryModel = externalRunner ? undefined : resolveEffectiveSubagentModel(
862
864
  s.model,
863
865
  a.model,
864
866
  ctx.currentModel,
865
867
  availableModels,
866
868
  a.modelProvider ?? ctx.currentModelProvider,
867
- { scope: modelScopes },
869
+ { scope: modelScopes, source: modelOrigin === "explicit" ? "explicit" : "inherited" },
868
870
  );
869
871
  const thinkingOverride = flatIndex === undefined ? undefined : thinkingOverridesByFlatIndex?.[flatIndex];
870
872
  const effectiveThinking = externalRunner ? undefined : thinkingOverride ?? a.thinking;
@@ -884,15 +886,17 @@ export function buildAsyncRunnerSteps(id: string, params: AsyncRunnerStepBuildPa
884
886
  }
885
887
  const agentContract = s.agentContract ?? params.agentContract;
886
888
  const permissionRules = resolvePermissionRules(ctx.permissions, a.permissions);
887
- const modelCandidates = externalRunner ? [] : buildModelCandidates(primaryModel, a.fallbackModels, availableModels, a.modelProvider ?? ctx.currentModelProvider, {
888
- scope: modelScopes,
889
- primaryModelFromParent,
890
- }).flatMap((candidate) => {
891
- const resolved = applyThinkingSuffix(candidate, effectiveThinking, thinkingOverride !== undefined);
892
- return resolved ? [resolved] : [];
893
- });
889
+ let modelCandidates: string[] = [];
894
890
  if (!externalRunner) {
895
891
  try {
892
+ modelCandidates = buildModelCandidates(primaryModel, a.fallbackModels, availableModels, a.modelProvider ?? ctx.currentModelProvider, {
893
+ scope: modelScopes,
894
+ primaryModelFromParent,
895
+ origin: modelOrigin,
896
+ }).flatMap((candidate) => {
897
+ const resolved = applyThinkingSuffix(candidate, effectiveThinking, thinkingOverride !== undefined);
898
+ return resolved ? [resolved] : [];
899
+ });
896
900
  for (const candidate of modelCandidates) assertThinkingWithinCeiling({ model: candidate, configThinking: effectiveThinking, ceiling: thinkingCeiling, agent: a.name, runId: id });
897
901
  } catch (error) {
898
902
  throw new AsyncStartValidationError(error instanceof Error ? error.message : String(error));
@@ -1588,15 +1592,27 @@ export function executeAsyncSingle(
1588
1592
  : "";
1589
1593
  const taskText = readsInstruction + taskWithOutputInstruction;
1590
1594
  const modelScopes = resolveModelScopesForAgent(ctx.modelScope, agentConfig.name, ctx.currentModel);
1591
- const primaryModel = externalRunner ? undefined : params.modelOverrideFromParent
1592
- ? params.modelOverride
1593
- : resolveSubagentModelOverride(
1594
- params.modelOverride ?? agentConfig.model,
1595
- ctx.currentModel,
1596
- availableModels,
1597
- ctx.currentModelProvider,
1598
- { scope: modelScopes },
1599
- );
1595
+ const modelOrigin = resolveModelOrigin({
1596
+ fromParent: params.modelOverrideFromParent,
1597
+ storedOrigin: params.modelOrigin,
1598
+ explicitModel: params.modelOverrideFromParent ? undefined : params.modelOverride,
1599
+ agentModel: agentConfig.model,
1600
+ parentModel: ctx.currentModel,
1601
+ });
1602
+ let primaryModel: string | undefined;
1603
+ try {
1604
+ primaryModel = externalRunner ? undefined : modelOrigin === "inherited"
1605
+ ? params.modelOverride ?? (ctx.currentModel ? `${ctx.currentModel.provider}/${ctx.currentModel.id}` : undefined)
1606
+ : resolveSubagentModelOverride(
1607
+ params.modelOverride ?? agentConfig.model,
1608
+ ctx.currentModel,
1609
+ availableModels,
1610
+ ctx.currentModelProvider,
1611
+ { scope: modelScopes, source: modelOrigin === "explicit" ? "explicit" : "inherited" },
1612
+ );
1613
+ } catch (error) {
1614
+ return formatAsyncStartError("single", error instanceof Error ? error.message : String(error));
1615
+ }
1600
1616
  const effectiveThinking = externalRunner ? undefined : params.thinkingOverride ?? agentConfig.thinking;
1601
1617
  const model = externalRunner ? undefined : applyThinkingSuffix(primaryModel, effectiveThinking, params.thinkingOverride !== undefined);
1602
1618
  const contextLimit = model ? findModelInfo(model, availableModels, agentConfig.modelProvider ?? ctx.currentModelProvider)?.contextWindow : undefined;
@@ -1633,18 +1649,17 @@ export function executeAsyncSingle(
1633
1649
  const structuredOutput = params.structuredOutputSchema
1634
1650
  ? createStructuredOutputRuntime(params.structuredOutputSchema, path.join(asyncDir, "structured-output"), { captureAcceptanceReport: params.acceptance !== false })
1635
1651
  : undefined;
1636
- const modelCandidates = externalRunner
1637
- ? []
1638
- : buildModelCandidates(primaryModel, agentConfig.fallbackModels, availableModels, agentConfig.modelProvider ?? ctx.currentModelProvider, {
1639
- scope: modelScopes,
1640
- primaryModelFromParent: params.modelOverrideFromParent,
1641
- })
1642
- .flatMap((candidate) => {
1652
+ let modelCandidates: string[] = [];
1653
+ if (!externalRunner) {
1654
+ try {
1655
+ modelCandidates = buildModelCandidates(primaryModel, agentConfig.fallbackModels, availableModels, agentConfig.modelProvider ?? ctx.currentModelProvider, {
1656
+ scope: modelScopes,
1657
+ primaryModelFromParent: modelOrigin === "inherited",
1658
+ origin: modelOrigin,
1659
+ }).flatMap((candidate) => {
1643
1660
  const resolved = applyThinkingSuffix(candidate, effectiveThinking, params.thinkingOverride !== undefined);
1644
1661
  return resolved ? [resolved] : [];
1645
1662
  });
1646
- if (!externalRunner) {
1647
- try {
1648
1663
  for (const candidate of modelCandidates) assertThinkingWithinCeiling({ model: candidate, configThinking: effectiveThinking, ceiling: thinkingCeiling, agent: agentConfig.name, runId: id });
1649
1664
  } catch (error) {
1650
1665
  return formatAsyncStartError("single", error instanceof Error ? error.message : String(error));
@@ -1730,7 +1745,8 @@ export function executeAsyncSingle(
1730
1745
  ...(model ? { model } : {}),
1731
1746
  ...(params.fast ?? recoveryAgentConfig.fast ? { fast: params.fast ?? recoveryAgentConfig.fast } : {}),
1732
1747
  ...(recoveryAgentConfig.modelProvider ? { modelProvider: recoveryAgentConfig.modelProvider } : {}),
1733
- ...(params.modelOverrideFromParent ? { modelOverrideFromParent: true } : {}),
1748
+ ...(modelOrigin === "inherited" ? { modelOverrideFromParent: true } : {}),
1749
+ modelOrigin,
1734
1750
  ...(recoveryAgentConfig.fallbackModels ? { fallbackModels: [...recoveryAgentConfig.fallbackModels] } : {}),
1735
1751
  ...(effectiveThinking ? { thinking: resolveEffectiveThinking(model, effectiveThinking) } : {}),
1736
1752
  ...(thinkingCeiling ? { thinkingCeiling } : {}),
@@ -1799,7 +1815,7 @@ export function executeAsyncSingle(
1799
1815
  thinking: resolveEffectiveThinking(model, effectiveThinking),
1800
1816
  ...(thinkingCeiling ? { thinkingCeiling } : {}),
1801
1817
  modelCandidates,
1802
- ...(params.modelOverrideFromParent ? { skipPrimaryModelVerification: true } : {}),
1818
+ ...(modelOrigin === "inherited" ? { skipPrimaryModelVerification: true } : {}),
1803
1819
  ...(availableModels && availableModels.length > 0 ? { modelVerificationRegistry: availableModels } : {}),
1804
1820
  tools: agentConfig.tools,
1805
1821
  allowNestedSubagents: agentConfig.allowNestedSubagents,
@@ -73,7 +73,22 @@ export function createAsyncJobTracker(pi: Pick<ExtensionAPI, "events">, state: S
73
73
  const steeringNoticeSeen = new Map<string, number>();
74
74
  const jobWatchers = new Map<string, { watchers: Map<string, fs.FSWatcher>; retryTimer?: ReturnType<typeof setTimeout> }>();
75
75
  const refreshTimers = new Map<string, ReturnType<typeof setTimeout>>();
76
+ let widgetRerenderTimer: ReturnType<typeof setTimeout> | undefined;
76
77
  const runningJobIds = new Set<string>();
78
+ const externalJobBridgeRuns = new Set<string>();
79
+ const externalJobBridgeEligibility = (steps: AsyncJobState["steps"]): "required" | "not-required" | "unknown" => {
80
+ if (!Array.isArray(steps)) return "unknown";
81
+ for (const step of steps) {
82
+ const runner = (step as { runner?: unknown } | null)?.runner;
83
+ if (runner === undefined) continue;
84
+ if (!runner || typeof runner !== "object" || Array.isArray(runner)) return "unknown";
85
+ const runnerType = (runner as { type?: unknown }).type;
86
+ if (typeof runnerType !== "string") return "unknown";
87
+ if (runnerType === "external-job") return "required";
88
+ if (runnerType !== "pi" && runnerType !== "external-cli") return "unknown";
89
+ }
90
+ return "not-required";
91
+ };
77
92
  let rootWatcher: fs.FSWatcher | undefined;
78
93
  let nextLivenessAt = Date.now() + livenessIntervalMs;
79
94
  let nextWidgetAnimationAt = Date.now() + WIDGET_ANIMATION_INTERVAL_MS;
@@ -94,9 +109,22 @@ export function createAsyncJobTracker(pi: Pick<ExtensionAPI, "events">, state: S
94
109
  const rerenderLastWidget = (jobs = Array.from(state.asyncJobs.values())) => {
95
110
  withLastUiContext((ctx) => rerenderWidget(ctx, jobs));
96
111
  };
112
+ const scheduleLastWidgetRerender = () => {
113
+ if (widgetRerenderTimer) return;
114
+ widgetRerenderTimer = setTimeout(() => {
115
+ widgetRerenderTimer = undefined;
116
+ rerenderLastWidget();
117
+ }, EVENT_REFRESH_DEBOUNCE_MS);
118
+ widgetRerenderTimer.unref?.();
119
+ };
97
120
  const requestLastWidgetRender = () => {
98
121
  if (options.widgetEnabled === false) return;
99
- rerenderLastWidget();
122
+ withLastUiContext((ctx) => {
123
+ if (state.widgetsSuspended) return;
124
+ const requestRender = (ctx.ui as { requestRender?: () => void }).requestRender;
125
+ if (requestRender) requestRender.call(ctx.ui);
126
+ else renderWidget(ctx, Array.from(state.asyncJobs.values()));
127
+ });
100
128
  };
101
129
  const refreshWidget = (ctx: ExtensionContext) => rerenderWidget(ctx);
102
130
  const restoredControlEventCursor = (asyncDir: string) => {
@@ -340,6 +368,7 @@ export function createAsyncJobTracker(pi: Pick<ExtensionAPI, "events">, state: S
340
368
  if (timer) clearTimeout(timer);
341
369
  refreshTimers.delete(asyncId);
342
370
  runningJobIds.delete(asyncId);
371
+ externalJobBridgeRuns.delete(asyncId);
343
372
  };
344
373
 
345
374
  const refreshJob = (job: AsyncJobState): boolean => {
@@ -354,9 +383,15 @@ export function createAsyncJobTracker(pi: Pick<ExtensionAPI, "events">, state: S
354
383
  console.error(`Failed to refresh nested async descendants for '${job.asyncDir}':`, error);
355
384
  }
356
385
  };
386
+ let bridgeSweepAttempted = false;
357
387
  try {
358
388
  emitNewControlEvents(job);
359
- serviceExternalJobBridgeRequests(job.asyncDir);
389
+ const bridgeAlreadyRequired = externalJobBridgeRuns.has(job.asyncId) || externalJobBridgeEligibility(job.steps) === "required";
390
+ if (bridgeAlreadyRequired) {
391
+ externalJobBridgeRuns.add(job.asyncId);
392
+ bridgeSweepAttempted = true;
393
+ serviceExternalJobBridgeRequests(job.asyncDir);
394
+ }
360
395
  try {
361
396
  if (job.nestedRoute) reconcileNestedAsyncDescendants(job.nestedRoute, { resultsDir, kill: options.kill, now: options.now });
362
397
  } catch (error) {
@@ -382,6 +417,15 @@ export function createAsyncJobTracker(pi: Pick<ExtensionAPI, "events">, state: S
382
417
  },
383
418
  });
384
419
  const status = reconciliation.status ?? readStatus(job.asyncDir);
420
+ if (!bridgeAlreadyRequired) {
421
+ const bridgeEligibility = externalJobBridgeEligibility(status?.steps);
422
+ if (bridgeEligibility === "required") externalJobBridgeRuns.add(job.asyncId);
423
+ // Missing or ambiguous status retains the legacy sweep for recovery races.
424
+ if (bridgeEligibility !== "not-required") {
425
+ bridgeSweepAttempted = true;
426
+ serviceExternalJobBridgeRequests(job.asyncDir);
427
+ }
428
+ }
385
429
  if (status) {
386
430
  const previousStatus = job.status;
387
431
  job.status = status.state;
@@ -456,6 +500,14 @@ export function createAsyncJobTracker(pi: Pick<ExtensionAPI, "events">, state: S
456
500
  runningJobIds.add(job.asyncId);
457
501
  }
458
502
  } catch (error) {
503
+ if (!bridgeSweepAttempted) {
504
+ try {
505
+ bridgeSweepAttempted = true;
506
+ serviceExternalJobBridgeRequests(job.asyncDir);
507
+ } catch (bridgeError) {
508
+ console.error(`Failed to service external job bridge for '${job.asyncDir}':`, bridgeError);
509
+ }
510
+ }
459
511
  if (job.status !== "failed") {
460
512
  console.error(`Failed to read async status for '${job.asyncDir}':`, error);
461
513
  job.status = "failed";
@@ -473,7 +525,7 @@ export function createAsyncJobTracker(pi: Pick<ExtensionAPI, "events">, state: S
473
525
  const timer = setTimeout(() => {
474
526
  refreshTimers.delete(asyncId);
475
527
  const job = state.asyncJobs.get(asyncId);
476
- if (job && refreshJob(job)) rerenderLastWidget();
528
+ if (job && refreshJob(job)) scheduleLastWidgetRerender();
477
529
  }, delayMs);
478
530
  timer.unref?.();
479
531
  refreshTimers.set(asyncId, timer);
@@ -604,6 +656,7 @@ export function createAsyncJobTracker(pi: Pick<ExtensionAPI, "events">, state: S
604
656
  : rawAgents;
605
657
  const sessionRoot = state.liveAsyncSessionRoots?.get(info.id);
606
658
  state.liveAsyncSessionRoots?.delete(info.id);
659
+ externalJobBridgeRuns.delete(info.id);
607
660
  state.asyncJobs.set(info.id, {
608
661
  asyncId: info.id,
609
662
  asyncDir,
@@ -677,11 +730,15 @@ export function createAsyncJobTracker(pi: Pick<ExtensionAPI, "events">, state: S
677
730
  for (const asyncId of jobWatchers.keys()) closeJobWatcher(asyncId);
678
731
  for (const timer of refreshTimers.values()) clearTimeout(timer);
679
732
  refreshTimers.clear();
733
+ if (widgetRerenderTimer) clearTimeout(widgetRerenderTimer);
734
+ widgetRerenderTimer = undefined;
680
735
  runningJobIds.clear();
736
+ externalJobBridgeRuns.clear();
681
737
  };
682
738
 
683
739
  const resetJobs = (ctx?: ExtensionContext) => {
684
740
  dispose();
741
+ state.statusProjectionSessionId = null;
685
742
  for (const timer of state.cleanupTimers.values()) clearTimeout(timer);
686
743
  state.cleanupTimers.clear();
687
744
  state.asyncJobs.clear();
@@ -698,6 +755,7 @@ export function createAsyncJobTracker(pi: Pick<ExtensionAPI, "events">, state: S
698
755
 
699
756
  const restoreActiveJobs = (ctx?: ExtensionContext) => {
700
757
  if (ctx?.hasUI) state.lastUiContext = ctx;
758
+ state.statusProjectionSessionId = null;
701
759
  if (!state.currentSessionId) return;
702
760
  let runs: AsyncRunSummary[];
703
761
  try {
@@ -713,6 +771,7 @@ export function createAsyncJobTracker(pi: Pick<ExtensionAPI, "events">, state: S
713
771
  rememberFleetJob(state, job);
714
772
  watchJob(job);
715
773
  }
774
+ state.statusProjectionSessionId = state.currentSessionId;
716
775
  if (runs.length === 0) return;
717
776
  ensurePoller();
718
777
  rerenderLastWidget();
@@ -314,7 +314,7 @@ export function readAsyncRecoveryDescriptor(asyncDir: string | undefined): Steer
314
314
  if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(`Invalid async recovery descriptor '${descriptorPath}': expected an object.`);
315
315
  const parsed = value as Record<string, unknown>;
316
316
  const allowedFields = new Set([
317
- "version", "launchContractDigest", "sourceRunId", "agentContract", "agent", "sessionFile", "cwd", "model", "modelProvider", "modelOverrideFromParent", "fallbackModels", "thinking", "thinkingCeiling", "tools", "allowNestedSubagents", "extensions",
317
+ "version", "launchContractDigest", "sourceRunId", "agentContract", "agent", "sessionFile", "cwd", "model", "modelProvider", "modelOverrideFromParent", "modelOrigin", "fallbackModels", "thinking", "thinkingCeiling", "tools", "allowNestedSubagents", "extensions",
318
318
  "subagentOnlyExtensions", "mcpDirectTools", "mutationTools", "systemPrompt", "systemPromptMode", "inheritProjectContext", "inheritGlobalContext", "inheritSkills", "skills",
319
319
  "skillPath", "agentFilePath", "completionGuard", "memory", "outputPath", "outputMode", "structuredOutputSchema", "acceptance", "sessionDir", "artifactConfig",
320
320
  "artifactsDir", "maxOutput", "controlConfig", "context", "intercomBridge", "absoluteDeadlineAt", "initialTurnBudget", "initialToolBudget", "maxSubagentDepth", "share", "capabilityCeiling",
@@ -347,6 +347,8 @@ export function readAsyncRecoveryDescriptor(asyncDir: string | undefined): Steer
347
347
  if (parsed.outputMode !== "inline" && parsed.outputMode !== "file-only") throw new Error(`Invalid async recovery descriptor '${descriptorPath}': outputMode is invalid.`);
348
348
  if (parsed.context !== undefined && parsed.context !== "fresh" && parsed.context !== "fork") throw new Error(`Invalid async recovery descriptor '${descriptorPath}': context is invalid.`);
349
349
  if (parsed.modelOverrideFromParent !== undefined && typeof parsed.modelOverrideFromParent !== "boolean") throw new Error(`Invalid async recovery descriptor '${descriptorPath}': modelOverrideFromParent must be a boolean.`);
350
+ if (parsed.modelOrigin !== undefined && parsed.modelOrigin !== "explicit" && parsed.modelOrigin !== "inherited" && parsed.modelOrigin !== "configured") throw new Error(`Invalid async recovery descriptor '${descriptorPath}': modelOrigin must be 'explicit', 'inherited', or 'configured'.`);
351
+ if (parsed.modelOrigin === undefined && parsed.model !== undefined) parsed.modelOrigin = parsed.modelOverrideFromParent ? "inherited" : "configured";
350
352
  for (const field of ["inheritProjectContext", "inheritSkills", "share"] as const) {
351
353
  if (typeof parsed[field] !== "boolean") throw new Error(`Invalid async recovery descriptor '${descriptorPath}': ${field} must be a boolean.`);
352
354
  }
@@ -166,6 +166,40 @@ function getErrorMessage(error: unknown): string {
166
166
  return error instanceof Error ? error.message : String(error);
167
167
  }
168
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
+
169
203
  function isNotFoundError(error: unknown): boolean {
170
204
  return typeof error === "object"
171
205
  && error !== null
@@ -250,14 +284,16 @@ function deriveAsyncActivityState(asyncDir: string, status: AsyncStatus): { acti
250
284
  }
251
285
 
252
286
  function statusToSummary(asyncDir: string, status: AsyncStatus & { cwd?: string }, nestedWarnings: string[] = [], nestedRoute?: NestedRoute): AsyncRunSummary {
253
- 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}'`);
254
289
  const workflowChildren = parseWorkflowChildSummary(status.workflowChildren);
255
- if (workflowChildren && workflowChildren.workflowRunId !== status.runId) throw new Error(`Invalid async status '${path.join(asyncDir, "status.json")}': workflowChildren.workflowRunId does not match.`);
256
- 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);
257
292
  const hostSteps = validHostStepNodes(status.workflowGraph);
258
293
  if (status.sessionId !== undefined && typeof status.sessionId !== "string") {
259
- 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.`);
260
295
  }
296
+ if (status.outputFile !== undefined && typeof status.outputFile !== "string") throw new Error(`Invalid async status '${statusPath}': outputFile must be a string.`);
261
297
  const { activityState, lastActivityAt } = deriveAsyncActivityState(asyncDir, status);
262
298
  const processTerminal = readProcessTerminal(asyncDir, { runId: status.runId, runnerProcessInstanceId: status.processTerminal?.runnerProcessInstanceId })
263
299
  ?? sanitizeProcessTerminal(status.processTerminal, { runId: status.runId, runnerProcessInstanceId: status.processTerminal?.runnerProcessInstanceId }, path.join(asyncDir, "status.json"));
@@ -499,10 +535,17 @@ export function listAsyncRuns(asyncDirRoot: string, options: AsyncRunListOptions
499
535
  };
500
536
  for (const entry of entries) {
501
537
  const asyncDir = path.join(asyncDirRoot, entry);
502
- const reconciliation = options.reconcile === false
503
- ? undefined
504
- : reconcileAsyncRun(asyncDir, { resultsDir: options.resultsDir, kill: options.kill, now: options.now });
505
- 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
+ }
506
549
  if (!status) {
507
550
  if (activeEntries.has(entry)) updateActiveRunIndex(asyncDir, "failed");
508
551
  continue;
@@ -528,7 +571,14 @@ export function listAsyncRuns(asyncDirRoot: string, options: AsyncRunListOptions
528
571
  nestedWarnings.push(`Nested status unavailable: ${getErrorMessage(error)}`);
529
572
  }
530
573
  }
531
- 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
+ }
532
582
  runs.push(summary);
533
583
  }
534
584
 
@@ -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
  }
@@ -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 {
@@ -128,7 +128,7 @@ function formatResumeGuidance(runId: string | undefined, children: Array<{ agent
128
128
  const resumableWorkflowChildren = workflowChildren.filter(({ child }) => !(child.status === "paused" && child.activityState === "needs_attention"));
129
129
  if (workflowChildren.length > 0) {
130
130
  return [
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 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.`),
132
132
  ...resumableWorkflowChildren.map(({ child }) => `Revive workflow child${typeof child.workflowKey === "string" && child.workflowKey.trim() ? ` '${child.workflowKey}'` : ""}: subagent({ action: "resume", id: "${child.runId}", message: "..." })`),
133
133
  ].join("\n");
134
134
  }
@@ -228,7 +228,7 @@ function formatRememberedForegroundStatus(run: ForegroundResumeRun): string {
228
228
  const detached = run.children.some((child) => child.status === "detached");
229
229
  const resumable = run.children.find((child) => hasExistingSessionFile(child.sessionFile));
230
230
  if (detached) {
231
- 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.`);
232
232
  } else if (resumable) {
233
233
  lines.push(run.children.length === 1
234
234
  ? `Revive: subagent({ action: "resume", id: "${run.runId}", message: "..." })`
@@ -2149,7 +2149,7 @@ async function runSingleStepInner(
2149
2149
  report: structuredAcceptanceReport as import("../../shared/types.ts").AcceptanceReport | undefined,
2150
2150
  reportError: structuredAcceptanceReportError,
2151
2151
  fileOutput: childWrittenOutput !== undefined && step.outputPath
2152
- ? { 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 }
2153
2153
  : undefined,
2154
2154
  cwd: step.cwd ?? ctx.cwd,
2155
2155
  signal: combinedAbortSignal([ctx.timeoutSignal, ctx.stopSignal]),
@@ -5347,7 +5347,6 @@ async function runSubagent(
5347
5347
  statusPayload.error = `Step failed: ${failedStep.agent}`;
5348
5348
  }
5349
5349
  }
5350
- writeStatusPayload();
5351
5350
  try {
5352
5351
  runPersistence.write(resultPath, {
5353
5352
  lifecycleArtifactVersion: SUBAGENT_LIFECYCLE_ARTIFACT_VERSION,