pi-subagents 0.65.0 → 0.66.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 (104) hide show
  1. package/CHANGELOG.md +87 -0
  2. package/README.md +1 -1
  3. package/agents/researcher.md +23 -13
  4. package/docs/agents.md +17 -3
  5. package/docs/configuration.md +34 -0
  6. package/docs/extension-api.md +94 -0
  7. package/docs/models.md +58 -1
  8. package/docs/observability.md +42 -2
  9. package/docs/tool-reference.md +11 -5
  10. package/docs/workflows.md +22 -7
  11. package/package.json +4 -1
  12. package/runner-server-preload.mjs +13 -0
  13. package/skills/pi-subagents/SKILL.md +2 -1
  14. package/skills/pi-subagents/references/execution-controls.md +19 -2
  15. package/skills/pi-subagents/references/management-authoring-rpc.md +2 -1
  16. package/skills/pi-subagents/references/multi-lane-orchestration.md +2 -0
  17. package/src/agents/advertised-agent-prompt.ts +63 -0
  18. package/src/agents/agent-management.ts +14 -1
  19. package/src/agents/agent-serializer.ts +2 -0
  20. package/src/agents/agents.ts +8 -0
  21. package/src/api/preflight.ts +5 -1
  22. package/src/api/shared-types.ts +1 -1
  23. package/src/api/workflow-resources.ts +6 -0
  24. package/src/extension/config.ts +4 -2
  25. package/src/extension/index.ts +71 -4
  26. package/src/extension/public-execution.ts +0 -1
  27. package/src/extension/rpc.ts +4 -21
  28. package/src/extension/schemas.ts +9 -7
  29. package/src/extension/tool-description.ts +10 -5
  30. package/src/integrations/pi-web-session-liveness.ts +73 -0
  31. package/src/intercom/native-supervisor-channel.ts +102 -88
  32. package/src/intercom/supervisor-ui.ts +3 -2
  33. package/src/missions/workflow-state.ts +37 -16
  34. package/src/runs/background/active-async-capacity.ts +18 -18
  35. package/src/runs/background/async-execution.ts +8 -1
  36. package/src/runs/background/async-job-tracker.ts +35 -3
  37. package/src/runs/background/async-resume.ts +3 -1
  38. package/src/runs/background/async-retention.ts +9 -0
  39. package/src/runs/background/async-status-snapshot.ts +10 -12
  40. package/src/runs/background/async-status.ts +17 -9
  41. package/src/runs/background/auto-drain.ts +40 -29
  42. package/src/runs/background/chain-root-attachment.ts +8 -0
  43. package/src/runs/background/control-channel.ts +78 -44
  44. package/src/runs/background/notify.ts +88 -12
  45. package/src/runs/background/owned-process-tree.ts +6 -6
  46. package/src/runs/background/process-terminal.ts +23 -23
  47. package/src/runs/background/retained-nested-route-tracker.ts +96 -0
  48. package/src/runs/background/run-child-session.ts +62 -33
  49. package/src/runs/background/run-status.ts +75 -5
  50. package/src/runs/background/runner-aliases.ts +46 -8
  51. package/src/runs/background/runner-child-launch.ts +86 -0
  52. package/src/runs/background/stale-run-reconciler.ts +3 -1
  53. package/src/runs/background/subagent-runner.ts +430 -208
  54. package/src/runs/background/subagent-wait.ts +3 -0
  55. package/src/runs/background/wait-completions.ts +4 -0
  56. package/src/runs/foreground/async-steering-action.ts +19 -0
  57. package/src/runs/foreground/execution.ts +116 -26
  58. package/src/runs/foreground/foreground-history.ts +3 -1
  59. package/src/runs/foreground/prompt-audit.ts +9 -5
  60. package/src/runs/foreground/subagent-executor.ts +531 -227
  61. package/src/runs/foreground/workflow-detach-reconcile.ts +8 -5
  62. package/src/runs/foreground/workflow-foreground-steering.ts +56 -2
  63. package/src/runs/shared/acceptance.ts +16 -3
  64. package/src/runs/shared/agent-contract.ts +1 -1
  65. package/src/runs/shared/async-status-projection.ts +47 -47
  66. package/src/runs/shared/child-hooks.ts +151 -2
  67. package/src/runs/shared/child-launch.ts +18 -13
  68. package/src/runs/shared/child-session.ts +55 -24
  69. package/src/runs/shared/child-tool-plan.ts +2 -2
  70. package/src/runs/shared/completion-evidence.ts +2 -2
  71. package/src/runs/shared/completion-guard.ts +1 -0
  72. package/src/runs/shared/host-step-status.ts +11 -11
  73. package/src/runs/shared/llm-intent-arbiter.ts +30 -20
  74. package/src/runs/shared/model-exclusions.ts +2 -1
  75. package/src/runs/shared/model-fallback.ts +41 -8
  76. package/src/runs/shared/nested-events.ts +8 -8
  77. package/src/runs/shared/orca-progress-tabs.ts +6 -0
  78. package/src/runs/shared/parallel-handoff.ts +57 -12
  79. package/src/runs/shared/parallel-utils.ts +3 -2
  80. package/src/runs/shared/readonly-drain-observation.ts +42 -0
  81. package/src/runs/shared/readonly-model-continuation.ts +69 -0
  82. package/src/runs/shared/readonly-session-evidence.ts +307 -0
  83. package/src/runs/shared/run-fanout-budget.ts +8 -8
  84. package/src/runs/shared/runtime-acknowledged-extensions.ts +3 -3
  85. package/src/runs/shared/subagent-prompt-runtime.ts +13 -3
  86. package/src/runs/shared/worktree-cleanup-plan.ts +6 -3
  87. package/src/runs/shared/worktree-setup-command.ts +190 -0
  88. package/src/runs/shared/worktree.ts +403 -210
  89. package/src/shared/model-response-aliases.ts +13 -0
  90. package/src/shared/types.ts +89 -60
  91. package/src/shared/utils.ts +10 -2
  92. package/src/shared/watch-strategy.ts +2 -0
  93. package/src/shared/workflow-child-permit.ts +18 -13
  94. package/src/tui/fleet-status.ts +1 -1
  95. package/src/tui/fleet.ts +11 -5
  96. package/src/tui/render.ts +44 -15
  97. package/src/workflows/chat-progress.ts +3 -3
  98. package/src/workflows/scripted-workflow.ts +70 -16
  99. package/src/workflows/workflow-checklist.ts +15 -18
  100. package/src/workflows/workflow-child-summary.ts +57 -8
  101. package/src/workflows/workflow-preflight.ts +19 -19
  102. package/src/workflows/workflow-receipt.ts +3 -3
  103. package/src/workflows/workflow-resources.ts +96 -21
  104. package/src/workflows/workflow-settlement.ts +3 -0
@@ -12,7 +12,7 @@ export const DEFAULT_ABANDONED_SLOT_RELEASE_AFTER_MS = 20 * 60 * 1000;
12
12
  export const MIN_ABANDONED_SLOT_RELEASE_AFTER_MS = 5 * 60 * 1000;
13
13
  export const MAX_ABANDONED_SLOT_RELEASE_AFTER_MS = 24 * 60 * 60 * 1000;
14
14
 
15
- export interface ActiveAsyncCapacityOwnerV1 {
15
+ export interface ActiveAsyncCapacityOwner {
16
16
  version: 1;
17
17
  reservationToken: string;
18
18
  ownerSessionId: string;
@@ -29,7 +29,7 @@ export interface ActiveAsyncCapacityOwnerV1 {
29
29
  }
30
30
 
31
31
  export interface ActiveAsyncCapacityHandle {
32
- readonly owner: ActiveAsyncCapacityOwnerV1;
32
+ readonly owner: ActiveAsyncCapacityOwner;
33
33
  markStarted(runnerProcessInstanceId: string): void;
34
34
  markWorkflowStarted(): void;
35
35
  rollback(): boolean;
@@ -44,7 +44,7 @@ interface CapacityOptions {
44
44
  abandonedSlotReleaseAfterMs?: number | false;
45
45
  pidLiveness?: (pid: number) => PidLiveness;
46
46
  afterSlotRename?: (releasedDir: string) => void;
47
- writeOwner?: (filePath: string, owner: ActiveAsyncCapacityOwnerV1) => void;
47
+ writeOwner?: (filePath: string, owner: ActiveAsyncCapacityOwner) => void;
48
48
  }
49
49
 
50
50
  export interface ActiveAsyncCapacityReleaseEvidence {
@@ -61,7 +61,7 @@ export type ActiveAsyncCapacityReleaseVerdict =
61
61
  | { state: "not-owned"; reason: string };
62
62
 
63
63
  export interface ActiveAsyncCapacityInspection {
64
- owner?: ActiveAsyncCapacityOwnerV1;
64
+ owner?: ActiveAsyncCapacityOwner;
65
65
  relation: "current" | "source" | "none";
66
66
  slotDir?: string;
67
67
  release: ActiveAsyncCapacityReleaseVerdict;
@@ -100,9 +100,9 @@ function slotDir(poolDir: string, slot: number): string {
100
100
  return path.join(poolDir, `slot-${slot}`);
101
101
  }
102
102
 
103
- function parseOwner(value: unknown): ActiveAsyncCapacityOwnerV1 | undefined {
103
+ function parseOwner(value: unknown): ActiveAsyncCapacityOwner | undefined {
104
104
  if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
105
- const owner = value as Partial<ActiveAsyncCapacityOwnerV1>;
105
+ const owner = value as Partial<ActiveAsyncCapacityOwner>;
106
106
  if (owner.version !== 1
107
107
  || typeof owner.reservationToken !== "string" || !owner.reservationToken
108
108
  || typeof owner.ownerSessionId !== "string" || !owner.ownerSessionId
@@ -116,10 +116,10 @@ function parseOwner(value: unknown): ActiveAsyncCapacityOwnerV1 | undefined {
116
116
  if (owner.sourceRunId !== undefined && typeof owner.sourceRunId !== "string") return undefined;
117
117
  if (owner.runnerProcessInstanceId !== undefined && (typeof owner.runnerProcessInstanceId !== "string" || !owner.runnerProcessInstanceId)) return undefined;
118
118
  if (owner.runnerStartedAt !== undefined && (typeof owner.runnerStartedAt !== "number" || !Number.isFinite(owner.runnerStartedAt))) return undefined;
119
- return owner as ActiveAsyncCapacityOwnerV1;
119
+ return owner as ActiveAsyncCapacityOwner;
120
120
  }
121
121
 
122
- function readOwner(dir: string): ActiveAsyncCapacityOwnerV1 | undefined {
122
+ function readOwner(dir: string): ActiveAsyncCapacityOwner | undefined {
123
123
  try {
124
124
  return parseOwner(JSON.parse(fs.readFileSync(path.join(dir, "owner.json"), "utf-8")));
125
125
  } catch {
@@ -142,7 +142,7 @@ function snapshotFor(sessionId: string, limit: number | undefined, rootDir: stri
142
142
  return { used: occupiedSlots(sessionDir(sessionId, rootDir)).length, limit: limit ?? 0 };
143
143
  }
144
144
 
145
- function matchingOwner(dir: string, expected: ActiveAsyncCapacityOwnerV1): ActiveAsyncCapacityOwnerV1 | undefined {
145
+ function matchingOwner(dir: string, expected: ActiveAsyncCapacityOwner): ActiveAsyncCapacityOwner | undefined {
146
146
  const owner = readOwner(dir);
147
147
  return owner?.reservationToken === expected.reservationToken
148
148
  && owner.runId === expected.runId
@@ -176,7 +176,7 @@ function withSlotClaim<T>(dir: string, operation: () => T): { acquired: true; va
176
176
  }
177
177
  }
178
178
 
179
- function removeOwnedSlot(dir: string, expected: ActiveAsyncCapacityOwnerV1, options: CapacityOptions, requireUnstarted = false, release?: ActiveAsyncCapacityReleaseVerdict): boolean {
179
+ function removeOwnedSlot(dir: string, expected: ActiveAsyncCapacityOwner, options: CapacityOptions, requireUnstarted = false, release?: ActiveAsyncCapacityReleaseVerdict): boolean {
180
180
  if (requireUnstarted && (expected.runnerProcessInstanceId || expected.runnerStartedAt)) return false;
181
181
  const claimed = withSlotClaim(dir, () => {
182
182
  const current = matchingOwner(dir, expected);
@@ -193,7 +193,7 @@ function removeOwnedSlot(dir: string, expected: ActiveAsyncCapacityOwnerV1, opti
193
193
  return claimed.acquired && claimed.value;
194
194
  }
195
195
 
196
- function appendAbandonedReleaseEvent(asyncDir: string, owner: ActiveAsyncCapacityOwnerV1, evidence: ActiveAsyncCapacityReleaseEvidence, now: number): void {
196
+ function appendAbandonedReleaseEvent(asyncDir: string, owner: ActiveAsyncCapacityOwner, evidence: ActiveAsyncCapacityReleaseEvidence, now: number): void {
197
197
  try {
198
198
  const eventsPath = path.join(asyncDir, "events.jsonl");
199
199
  fs.mkdirSync(path.dirname(eventsPath), { recursive: true });
@@ -213,7 +213,7 @@ function terminalState(state: AsyncStatus["state"]): boolean {
213
213
  return state !== "queued" && state !== "running" && state !== "paused";
214
214
  }
215
215
 
216
- function runnerReleaseVerdict(owner: ActiveAsyncCapacityOwnerV1, status: AsyncStatus | null, options: CapacityOptions): ActiveAsyncCapacityReleaseVerdict {
216
+ function runnerReleaseVerdict(owner: ActiveAsyncCapacityOwner, status: AsyncStatus | null, options: CapacityOptions): ActiveAsyncCapacityReleaseVerdict {
217
217
  if (!status) return { state: "retained", reason: "status file is missing or unreadable" };
218
218
  if (!owner.runnerProcessInstanceId) return { state: "retained", reason: "runner process identity has not been recorded" };
219
219
  if (status.sessionId !== owner.ownerSessionId) return { state: "retained", reason: `status session ${status.sessionId ?? "unknown"} does not match owner session ${owner.ownerSessionId}` };
@@ -262,7 +262,7 @@ function abandonedRunnerReleaseVerdict(status: AsyncStatus, proofState: string,
262
262
  };
263
263
  }
264
264
 
265
- function workflowReleaseVerdict(owner: ActiveAsyncCapacityOwnerV1, status: AsyncStatus | null, liveWorkflowRunIds: ReadonlySet<string>): ActiveAsyncCapacityReleaseVerdict {
265
+ function workflowReleaseVerdict(owner: ActiveAsyncCapacityOwner, status: AsyncStatus | null, liveWorkflowRunIds: ReadonlySet<string>): ActiveAsyncCapacityReleaseVerdict {
266
266
  if (!status) return { state: "retained", reason: "status file is missing or unreadable" };
267
267
  if (status.sessionId !== owner.ownerSessionId) return { state: "retained", reason: `status session ${status.sessionId ?? "unknown"} does not match owner session ${owner.ownerSessionId}` };
268
268
  if (status.runId !== owner.runId) return { state: "retained", reason: `status run ${status.runId} does not match owner run ${owner.runId}` };
@@ -289,7 +289,7 @@ function workflowReleaseVerdict(owner: ActiveAsyncCapacityOwnerV1, status: Async
289
289
  return { state: "releasable", reason: "workflow is terminal, controller is gone, and async children have observed proof" };
290
290
  }
291
291
 
292
- function ownerReleaseVerdict(owner: ActiveAsyncCapacityOwnerV1, liveWorkflowRunIds: ReadonlySet<string>, options: CapacityOptions): ActiveAsyncCapacityReleaseVerdict {
292
+ function ownerReleaseVerdict(owner: ActiveAsyncCapacityOwner, liveWorkflowRunIds: ReadonlySet<string>, options: CapacityOptions): ActiveAsyncCapacityReleaseVerdict {
293
293
  const status = readStatus(owner.asyncDir);
294
294
  return owner.kind === "runner"
295
295
  ? runnerReleaseVerdict(owner, status, options)
@@ -364,7 +364,7 @@ export function getActiveAsyncCapacitySnapshot(
364
364
  return reconcileActiveAsyncCapacity(sessionId, limit, options);
365
365
  }
366
366
 
367
- function createSlot(poolDir: string, owner: ActiveAsyncCapacityOwnerV1): boolean {
367
+ function createSlot(poolDir: string, owner: ActiveAsyncCapacityOwner): boolean {
368
368
  const destination = slotDir(poolDir, owner.slot);
369
369
  fs.mkdirSync(poolDir, { recursive: true, mode: 0o700 });
370
370
  try {
@@ -379,7 +379,7 @@ function createSlot(poolDir: string, owner: ActiveAsyncCapacityOwnerV1): boolean
379
379
  return true;
380
380
  }
381
381
 
382
- function handleFor(owner: ActiveAsyncCapacityOwnerV1, limit: number, options: CapacityOptions, rollbackOwner?: ActiveAsyncCapacityOwnerV1): ActiveAsyncCapacityHandle {
382
+ function handleFor(owner: ActiveAsyncCapacityOwner, limit: number, options: CapacityOptions, rollbackOwner?: ActiveAsyncCapacityOwner): ActiveAsyncCapacityHandle {
383
383
  const rootDir = options.rootDir ?? ACTIVE_ASYNC_CAPACITY_DIR;
384
384
  const writeOwner = options.writeOwner ?? writePrivateAtomicJson;
385
385
  const dir = slotDir(sessionDir(owner.ownerSessionId, rootDir), owner.slot);
@@ -462,7 +462,7 @@ export function acquireActiveAsyncCapacity(
462
462
  const poolDir = sessionDir(input.sessionId, rootDir);
463
463
  const token = options.token?.() ?? randomUUID();
464
464
  for (let slot = 0; slot < input.limit; slot++) {
465
- const owner: ActiveAsyncCapacityOwnerV1 = {
465
+ const owner: ActiveAsyncCapacityOwner = {
466
466
  version: 1,
467
467
  reservationToken: token,
468
468
  ownerSessionId: input.sessionId,
@@ -495,7 +495,7 @@ export function transferActiveAsyncCapacity(
495
495
  if (!current || !status || status.runId !== input.sourceRunId || status.state === "queued" || status.state === "running") {
496
496
  throw new Error(`Active async capacity source '${input.sourceRunId}' is not transferable.`);
497
497
  }
498
- const next: ActiveAsyncCapacityOwnerV1 = {
498
+ const next: ActiveAsyncCapacityOwner = {
499
499
  ...current,
500
500
  runId: input.runId,
501
501
  sourceRunId: input.sourceRunId,
@@ -146,6 +146,7 @@ interface AsyncExecutionContext {
146
146
  currentModel?: ParentModel;
147
147
  /** Optional model-scope enforcement resolved from subagent settings. */
148
148
  modelScope?: ModelScopeConfig;
149
+ modelResponseAliases?: Record<string, string[]>;
149
150
  /** Whether the parent session has an interactive UI. */
150
151
  interactive?: boolean;
151
152
  /** The executor's own child runtime when the launch comes from an in-process child. */
@@ -586,7 +587,10 @@ function spawnRunner(cfg: object, suffix: string, cwd: string, initialStatus: Om
586
587
  stdoutFd = fs.openSync(logPaths.stdoutPath, "a");
587
588
  stderrFd = fs.openSync(logPaths.stderrPath, "a");
588
589
  }
589
- const proc = spawn(nodeCommand, [jitiCliPath, runner, cfgPath], {
590
+ const preload = hostPeerAliases.supplemental.length > 0
591
+ ? ["--import", new URL("../../../runner-server-preload.mjs", import.meta.url).href]
592
+ : [];
593
+ const proc = spawn(nodeCommand, [...preload, jitiCliPath, runner, cfgPath], {
590
594
  cwd,
591
595
  ...backgroundProcessOptions(),
592
596
  stdio: ["ignore", stdoutFd ?? "ignore", stderrFd ?? "ignore"],
@@ -1021,6 +1025,7 @@ export function buildAsyncRunnerSteps(id: string, params: AsyncRunnerStepBuildPa
1021
1025
  modelCandidates: externalRunner ? undefined : modelCandidates,
1022
1026
  ...(primaryModelFromParent ? { skipPrimaryModelVerification: true } : {}),
1023
1027
  ...(availableModels && availableModels.length > 0 ? { modelVerificationRegistry: availableModels } : {}),
1028
+ ...(ctx.modelResponseAliases ? { modelResponseAliases: ctx.modelResponseAliases } : {}),
1024
1029
  tools: a.tools,
1025
1030
  excludeTools: a.excludeTools,
1026
1031
  allowNestedSubagents: a.allowNestedSubagents,
@@ -1822,6 +1827,7 @@ export function executeAsyncSingle(
1822
1827
  });
1823
1828
  const recoveryAgentConfig = params.recoveryAgentConfig ?? agentConfig;
1824
1829
  const recoveryDescriptor: SteeringRecoveryDescriptor = {
1830
+ ...(ctx.modelResponseAliases ? { modelResponseAliases: ctx.modelResponseAliases } : {}),
1825
1831
  version: 1,
1826
1832
  ...(lane ? { lane } : {}),
1827
1833
  launchContractDigest,
@@ -1910,6 +1916,7 @@ export function executeAsyncSingle(
1910
1916
  modelCandidates,
1911
1917
  ...(modelOrigin === "inherited" ? { skipPrimaryModelVerification: true } : {}),
1912
1918
  ...(availableModels && availableModels.length > 0 ? { modelVerificationRegistry: availableModels } : {}),
1919
+ ...(ctx.modelResponseAliases ? { modelResponseAliases: ctx.modelResponseAliases } : {}),
1913
1920
  tools: agentConfig.tools,
1914
1921
  excludeTools: agentConfig.excludeTools,
1915
1922
  allowNestedSubagents: agentConfig.allowNestedSubagents,
@@ -26,6 +26,7 @@ import { EXTERNAL_JOB_BRIDGE_REQUEST_DIR, serviceExternalJobBridgeRequests } fro
26
26
  import { shouldUseNativeFsWatch } from "../../shared/watch-strategy.ts";
27
27
  import { parseWorkflowChildSummary } from "../../workflows/workflow-child-summary.ts";
28
28
  import { validHostStepNodes } from "../shared/host-step-status.ts";
29
+ import { readProcessTerminal } from "./process-terminal.ts";
29
30
  import { withCachedUiContext } from "../../shared/extension-context.ts";
30
31
 
31
32
  interface AsyncJobTrackerOptions {
@@ -78,6 +79,9 @@ export function createAsyncJobTracker(pi: Pick<ExtensionAPI, "events">, state: S
78
79
  let widgetRerenderTimer: ReturnType<typeof setTimeout> | undefined;
79
80
  const runningJobIds = new Set<string>();
80
81
  const externalJobBridgeRuns = new Set<string>();
82
+ // Early native failure is visible before its publisher finishes. Retain only
83
+ // that scoped observation, using the existing liveness sweep to renew delivery.
84
+ const terminalPublications = new Map<string, { instanceId: string; pending: true } | { pending: false }>();
81
85
  const externalJobBridgeEligibility = (steps: AsyncJobState["steps"]): "required" | "not-required" | "unknown" => {
82
86
  if (!Array.isArray(steps)) return "unknown";
83
87
  for (const step of steps) {
@@ -380,6 +384,7 @@ export function createAsyncJobTracker(pi: Pick<ExtensionAPI, "events">, state: S
380
384
  refreshTimers.delete(asyncId);
381
385
  runningJobIds.delete(asyncId);
382
386
  externalJobBridgeRuns.delete(asyncId);
387
+ terminalPublications.delete(asyncId);
383
388
  };
384
389
 
385
390
  const refreshJob = (job: AsyncJobState): boolean => {
@@ -440,6 +445,7 @@ export function createAsyncJobTracker(pi: Pick<ExtensionAPI, "events">, state: S
440
445
  if (status) {
441
446
  const previousStatus = job.status;
442
447
  job.status = status.state;
448
+ if (!terminalStatus(job.status)) terminalPublications.delete(job.asyncId);
443
449
  if (job.status === "running") runningJobIds.add(job.asyncId);
444
450
  else runningJobIds.delete(job.asyncId);
445
451
  if (job.status !== "complete" && job.status !== "failed" && job.status !== "paused" && job.status !== "stopped") cancelCleanup(job.asyncId);
@@ -497,9 +503,30 @@ export function createAsyncJobTracker(pi: Pick<ExtensionAPI, "events">, state: S
497
503
  job.wrapUpRequested = status.wrapUpRequested ?? job.wrapUpRequested;
498
504
  job.sessionFile = status.sessionFile ?? job.sessionFile;
499
505
  if (terminalStatus(job.status)) {
500
- if (!terminalStatus(previousStatus)) options.onJobTerminal?.();
506
+ let publication = terminalPublications.get(job.asyncId);
507
+ if (!publication && status.mode !== "workflow" && status.processTerminal?.state === "pending"
508
+ && status.runId === job.asyncId && status.sessionId === state.currentSessionId
509
+ && status.completionOwnerId && status.completionOwnerId === state.completionOwnerId) {
510
+ publication = { instanceId: status.processTerminal.runnerProcessInstanceId, pending: true };
511
+ terminalPublications.set(job.asyncId, publication);
512
+ }
513
+ const wasPending = publication?.pending;
514
+ if (publication?.pending) {
515
+ // Reconciliation resolves pending as well as public payloads. The
516
+ // watcher already discovers exact tracked run IDs without promotion.
517
+ const published = reconciliation.resultPath && fs.existsSync(reconciliation.resultPath);
518
+ const closed = !published && readProcessTerminal(job.asyncDir, {
519
+ runId: job.asyncId, runnerProcessInstanceId: publication.instanceId,
520
+ })?.state === "observed";
521
+ if (published || closed) {
522
+ publication = { pending: false };
523
+ terminalPublications.set(job.asyncId, publication);
524
+ } else cancelCleanup(job.asyncId);
525
+ }
526
+ // Scan on close too: publication may have raced the payload check.
527
+ if (!terminalStatus(previousStatus) || (wasPending && !publication?.pending)) options.onJobTerminal?.();
501
528
  rememberFleetJob(state, job);
502
- if (!nestedRefreshFailed && !hasLiveNestedDescendants(job.nestedChildren) && (previousStatus !== job.status || !state.cleanupTimers.has(job.asyncId))) {
529
+ if (!publication?.pending && !nestedRefreshFailed && !hasLiveNestedDescendants(job.nestedChildren) && (previousStatus !== job.status || !state.cleanupTimers.has(job.asyncId))) {
503
530
  scheduleCleanup(job.asyncId);
504
531
  }
505
532
  }
@@ -526,7 +553,7 @@ export function createAsyncJobTracker(pi: Pick<ExtensionAPI, "events">, state: S
526
553
  }
527
554
  runningJobIds.delete(job.asyncId);
528
555
  rememberFleetJob(state, job);
529
- if (!hasLiveNestedDescendants(job.nestedChildren) && !state.cleanupTimers.has(job.asyncId)) scheduleCleanup(job.asyncId);
556
+ if (!terminalPublications.get(job.asyncId)?.pending && !hasLiveNestedDescendants(job.nestedChildren) && !state.cleanupTimers.has(job.asyncId)) scheduleCleanup(job.asyncId);
530
557
  }
531
558
  return widgetRenderKey(job, widgetExpanded) !== widgetStateBefore;
532
559
  };
@@ -668,6 +695,7 @@ export function createAsyncJobTracker(pi: Pick<ExtensionAPI, "events">, state: S
668
695
  const sessionRoot = state.liveAsyncSessionRoots?.get(info.id);
669
696
  state.liveAsyncSessionRoots?.delete(info.id);
670
697
  externalJobBridgeRuns.delete(info.id);
698
+ terminalPublications.delete(info.id);
671
699
  state.asyncJobs.set(info.id, {
672
700
  asyncId: info.id,
673
701
  asyncDir,
@@ -721,6 +749,9 @@ export function createAsyncJobTracker(pi: Pick<ExtensionAPI, "events">, state: S
721
749
  job.asyncDir = result.asyncDir;
722
750
  watchJob(job);
723
751
  }
752
+ // Delivery can precede the first terminal status refresh. Remember its
753
+ // settlement even when no pending publication has been observed yet.
754
+ terminalPublications.set(asyncId, { pending: false });
724
755
  try {
725
756
  updateAsyncJobNestedProjection(job);
726
757
  } catch (error) {
@@ -745,6 +776,7 @@ export function createAsyncJobTracker(pi: Pick<ExtensionAPI, "events">, state: S
745
776
  widgetRerenderTimer = undefined;
746
777
  runningJobIds.clear();
747
778
  externalJobBridgeRuns.clear();
779
+ terminalPublications.clear();
748
780
  };
749
781
 
750
782
  const resetJobs = (ctx?: ExtensionContext) => {
@@ -15,6 +15,7 @@ import { parallelHandoffPath, resolveRetainedWorktreeCwd } from "../shared/paral
15
15
  import { normalizeWorktreeBaseRef } from "../shared/worktree.ts";
16
16
  import { intersectThinkingCeilings, parseThinkingLevel, type ThinkingLevel } from "../../shared/thinking-ceiling.ts";
17
17
  import { assertWorkflowGraphHostSteps } from "../shared/host-step-status.ts";
18
+ import { validateModelResponseAliases } from "../../shared/model-response-aliases.ts";
18
19
 
19
20
  export interface AsyncResumeParams {
20
21
  id?: string;
@@ -317,7 +318,7 @@ export function readAsyncRecoveryDescriptor(asyncDir: string | undefined): Steer
317
318
  if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(`Invalid async recovery descriptor '${descriptorPath}': expected an object.`);
318
319
  const parsed = value as Record<string, unknown>;
319
320
  const allowedFields = new Set([
320
- "version", "launchContractDigest", "sourceRunId", "agentContract", "agent", "sessionFile", "cwd", "model", "modelProvider", "modelOverrideFromParent", "modelOrigin", "fallbackModels", "thinking", "thinkingCeiling", "tools", "allowNestedSubagents", "extensions",
321
+ "modelResponseAliases", "version", "launchContractDigest", "sourceRunId", "agentContract", "agent", "sessionFile", "cwd", "model", "modelProvider", "modelOverrideFromParent", "modelOrigin", "fallbackModels", "thinking", "thinkingCeiling", "tools", "allowNestedSubagents", "extensions",
321
322
  "subagentOnlyExtensions", "mcpDirectTools", "excludeTools", "mutationTools", "systemPrompt", "systemPromptMode", "inheritProjectContext", "inheritGlobalContext", "inheritSkills", "skills",
322
323
  "skillPath", "agentFilePath", "completionGuard", "memory", "outputPath", "outputMode", "structuredOutputSchema", "acceptance", "sessionDir", "artifactConfig",
323
324
  "artifactsDir", "maxOutput", "controlConfig", "context", "intercomBridge", "absoluteDeadlineAt", "initialTurnBudget", "initialToolBudget", "maxSubagentDepth", "share", "capabilityCeiling",
@@ -337,6 +338,7 @@ export function readAsyncRecoveryDescriptor(asyncDir: string | undefined): Steer
337
338
  } catch (error) {
338
339
  throw new Error(`Invalid async recovery descriptor '${descriptorPath}': ${error instanceof Error ? error.message : String(error)}`);
339
340
  }
341
+ validateModelResponseAliases(parsed.modelResponseAliases, `async recovery descriptor '${descriptorPath}' modelResponseAliases`);
340
342
  if (parsed.capabilityCeiling !== undefined) parsed.capabilityCeiling = parseSubagentCapabilityCeiling(parsed.capabilityCeiling, `async recovery descriptor '${descriptorPath}' capabilityCeiling`);
341
343
  if (parsed.thinkingCeiling !== undefined) parsed.thinkingCeiling = parseThinkingLevel(parsed.thinkingCeiling, `async recovery descriptor '${descriptorPath}' thinkingCeiling`);
342
344
  if (parsed.extensionBindings !== undefined) parsed.extensionBindings = normalizeExtensionBindings(parsed.extensionBindings)!.value;
@@ -320,7 +320,16 @@ function readCursor(root: string): RetentionCursor {
320
320
  return value?.version === 1 ? value as unknown as RetentionCursor : { version: 1 };
321
321
  }
322
322
 
323
+ let currentProcessStartIdentity: string | undefined | null = null;
324
+
323
325
  function processStartIdentity(pid: number): string | undefined {
326
+ // Foreign PIDs can be reused while this process lives, so resolve them afresh.
327
+ if (pid !== process.pid) return computeProcessStartIdentity(pid);
328
+ if (currentProcessStartIdentity === null) currentProcessStartIdentity = computeProcessStartIdentity(pid);
329
+ return currentProcessStartIdentity;
330
+ }
331
+
332
+ function computeProcessStartIdentity(pid: number): string | undefined {
324
333
  if (process.platform === "linux") {
325
334
  try {
326
335
  const stat = fs.readFileSync(`/proc/${pid}/stat`).toString("utf8");
@@ -1,8 +1,8 @@
1
1
  import type { AsyncJobState, SubagentState } from "../../shared/types.ts";
2
2
  import {
3
- projectAsyncStatusSnapshot,
3
+ projectAsyncStatusSnapshot as buildAsyncStatusSnapshot,
4
4
  type AsyncStatusSnapshotOptions,
5
- type AsyncStatusSnapshotV1,
5
+ type AsyncStatusSnapshot,
6
6
  } from "../shared/async-status-projection.ts";
7
7
 
8
8
  export {
@@ -10,22 +10,20 @@ export {
10
10
  ASYNC_STATUS_SNAPSHOT_VERSION,
11
11
  } from "../shared/async-status-projection.ts";
12
12
  export type {
13
- AsyncStatusSnapshotActivityV1,
14
- AsyncStatusSnapshotCapsV1,
15
- AsyncStatusSnapshotHostStepV1,
13
+ AsyncStatusSnapshotActivity,
14
+ AsyncStatusSnapshotCaps,
15
+ AsyncStatusSnapshotHostStep,
16
16
  AsyncStatusSnapshotKind,
17
- AsyncStatusSnapshotNodeV1,
18
- AsyncStatusSnapshotOmittedV1,
17
+ AsyncStatusSnapshotNode,
18
+ AsyncStatusSnapshotOmitted,
19
19
  AsyncStatusSnapshotOptions,
20
20
  AsyncStatusSnapshotState,
21
- AsyncStatusSnapshotV1,
21
+ AsyncStatusSnapshot,
22
22
  } from "../shared/async-status-projection.ts";
23
23
 
24
24
  export const ASYNC_STATUS_SNAPSHOT_WIDGET_PREFIX = "PI_SUBAGENT_ASYNC_JSON:";
25
25
 
26
- export function buildAsyncStatusSnapshot(jobs: Iterable<AsyncJobState>, options: AsyncStatusSnapshotOptions = {}): AsyncStatusSnapshotV1 {
27
- return projectAsyncStatusSnapshot(jobs, options);
28
- }
26
+ export { buildAsyncStatusSnapshot };
29
27
 
30
28
  export function asyncStatusSnapshotJobsForState(state: SubagentState | undefined, sessionId: string | null | undefined): AsyncJobState[] {
31
29
  if (!state || !sessionId || state.currentSessionId !== sessionId) return [];
@@ -39,7 +37,7 @@ export function asyncStatusSnapshotJobsForState(state: SubagentState | undefined
39
37
  return [...jobs.values()];
40
38
  }
41
39
 
42
- export function buildAsyncStatusSnapshotForState(state: SubagentState | undefined, sessionId: string | null | undefined, options: AsyncStatusSnapshotOptions = {}): AsyncStatusSnapshotV1 {
40
+ export function buildAsyncStatusSnapshotForState(state: SubagentState | undefined, sessionId: string | null | undefined, options: AsyncStatusSnapshotOptions = {}): AsyncStatusSnapshot {
43
41
  return buildAsyncStatusSnapshot(asyncStatusSnapshotJobsForState(state, sessionId), options);
44
42
  }
45
43
 
@@ -3,7 +3,7 @@ import * as path from "node:path";
3
3
  import { formatDuration, formatModelThinking, formatTokens, shortenPath } from "../../shared/formatters.ts";
4
4
  import { previewDisplayText } from "../../shared/display-text.ts";
5
5
  import { formatActivityLabel, formatParallelOutcome } from "../../shared/status-format.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 WorktreeNaming, type WorkflowPreflightV1, type WorkflowGraphSnapshot } from "../../shared/types.ts";
6
+ import { type ActivityState, type AsyncJobStep, type AsyncParallelGroupStatus, type AsyncStatus, type CostSummary, type Details, type HostStepNode, type HostStepState, type LaunchResolvedChildExtensions, type RuntimeAcknowledgedChildExtensions, type NestedRunSummary, type SteeringStatus, type SubagentRunMode, type TimeoutRecoveryProjection, type TokenUsage, type TurnBudgetState, type UsageBudgetState, type WorktreeNaming, type WorkflowPreflight, type WorkflowGraphSnapshot } from "../../shared/types.ts";
7
7
  import type { ResolvedSubagentCapabilityCeiling, SubagentCapabilityAudit } from "../shared/capability-ceiling.ts";
8
8
  import { readStatus } from "../../shared/utils.ts";
9
9
  import { attachRootChildrenToSteps, buildNestedRouteIndex, findNestedRouteForRootId, type NestedRoute, projectNestedEvents } from "../shared/nested-events.ts";
@@ -25,6 +25,7 @@ import { formatWorkflowPreflightPlanSummary, formatWorkflowPreflightWarningSumma
25
25
  import { workflowGraphStageNodes } from "../shared/workflow-graph.ts";
26
26
  import { formatTimeoutRecoveryLines, projectTimeoutRecovery } from "../shared/mutation-evidence.ts";
27
27
  import { formatWorkflowChecklistText, projectWorkflowChecklist } from "../../workflows/workflow-checklist.ts";
28
+ import type { RawDrainStatusObserver } from "../shared/readonly-drain-observation.ts";
28
29
 
29
30
  interface AsyncRunStepSummary {
30
31
  index: number;
@@ -82,8 +83,8 @@ interface AsyncRunStepSummary {
82
83
  effects?: AsyncJobStep["effects"];
83
84
  processTerminal?: AsyncJobStep["processTerminal"];
84
85
  timeoutRecovery?: TimeoutRecoveryProjection;
85
- launchResolvedExtensions?: LaunchResolvedChildExtensionsV1;
86
- runtimeAcknowledgedExtensions?: RuntimeAcknowledgedChildExtensionsV1;
86
+ launchResolvedExtensions?: LaunchResolvedChildExtensions;
87
+ runtimeAcknowledgedExtensions?: RuntimeAcknowledgedChildExtensions;
87
88
  capabilityCeiling?: ResolvedSubagentCapabilityCeiling;
88
89
  capabilityAudit?: SubagentCapabilityAudit;
89
90
  children?: NestedRunSummary[];
@@ -122,7 +123,7 @@ export interface AsyncRunSummary {
122
123
  chainStepCount?: number;
123
124
  pendingAppends?: number;
124
125
  parallelGroups?: AsyncParallelGroupStatus[];
125
- hostSteps?: HostStepNodeV1[];
126
+ hostSteps?: HostStepNode[];
126
127
  workflowGraph?: AsyncStatus["workflowGraph"];
127
128
  steps: AsyncRunStepSummary[];
128
129
  sessionDir?: string;
@@ -135,8 +136,8 @@ export interface AsyncRunSummary {
135
136
  nestedWarnings?: string[];
136
137
  processTerminal?: AsyncStatus["processTerminal"];
137
138
  runFanoutBudget?: AsyncStatus["runFanoutBudget"];
138
- launchResolvedExtensions?: LaunchResolvedChildExtensionsV1;
139
- runtimeAcknowledgedExtensions?: RuntimeAcknowledgedChildExtensionsV1;
139
+ launchResolvedExtensions?: LaunchResolvedChildExtensions;
140
+ runtimeAcknowledgedExtensions?: RuntimeAcknowledgedChildExtensions;
140
141
  capabilityCeiling?: ResolvedSubagentCapabilityCeiling;
141
142
  capabilityAudit?: SubagentCapabilityAudit;
142
143
  parentWorkflowRunId?: string;
@@ -144,7 +145,7 @@ export interface AsyncRunSummary {
144
145
  lane?: AsyncStatus["lane"];
145
146
  workflow?: Details["workflow"];
146
147
  workflowChildren?: Details["workflowChildren"];
147
- preflight?: WorkflowPreflightV1;
148
+ preflight?: WorkflowPreflight;
148
149
  }
149
150
 
150
151
  interface AsyncRunListOptions {
@@ -481,7 +482,7 @@ function sortRuns(runs: AsyncRunSummary[]): AsyncRunSummary[] {
481
482
  });
482
483
  }
483
484
 
484
- export function listAsyncRuns(asyncDirRoot: string, options: AsyncRunListOptions = {}): AsyncRunSummary[] {
485
+ export function listAsyncRuns(asyncDirRoot: string, options: AsyncRunListOptions = {}, observeStatus?: RawDrainStatusObserver): AsyncRunSummary[] {
485
486
  let entries: string[];
486
487
  const activeEntries = new Set<string>();
487
488
  const wantsActive = options.states === undefined || options.states.some(isActiveAsyncState);
@@ -508,6 +509,7 @@ export function listAsyncRuns(asyncDirRoot: string, options: AsyncRunListOptions
508
509
  indexed.add(entry);
509
510
  activeEntries.add(entry);
510
511
  } else {
512
+ observeStatus?.(null);
511
513
  updateActiveRunIndex(path.join(asyncDirRoot, entry), "failed");
512
514
  }
513
515
  }
@@ -519,6 +521,7 @@ export function listAsyncRuns(asyncDirRoot: string, options: AsyncRunListOptions
519
521
  }
520
522
  } catch (error) {
521
523
  if (isNotFoundError(error)) return [];
524
+ observeStatus?.(null);
522
525
  throw new Error(`Failed to list async runs in '${asyncDirRoot}': ${getErrorMessage(error)}`, {
523
526
  cause: error instanceof Error ? error : undefined,
524
527
  });
@@ -544,14 +547,17 @@ export function listAsyncRuns(asyncDirRoot: string, options: AsyncRunListOptions
544
547
  try {
545
548
  const reconciliation = options.reconcile === false
546
549
  ? undefined
547
- : reconcileAsyncRun(asyncDir, { resultsDir: options.resultsDir, kill: options.kill, now: options.now });
550
+ : reconcileAsyncRun(asyncDir, { resultsDir: options.resultsDir, kill: options.kill, now: options.now }, observeStatus);
548
551
  status = (reconciliation?.status ?? readStatus(asyncDir)) as (AsyncStatus & { cwd?: string }) | null;
552
+ if (options.reconcile === false) observeStatus?.(status);
549
553
  } catch (error) {
554
+ observeStatus?.(null);
550
555
  if (!activeEntries.has(entry) || !isAsyncStatusIsolationError(asyncDir, error)) throw error;
551
556
  isolateCorruptActiveRun(asyncDir, entry, error, options.now);
552
557
  continue;
553
558
  }
554
559
  if (!status) {
560
+ observeStatus?.(null);
555
561
  if (activeEntries.has(entry)) updateActiveRunIndex(asyncDir, "failed");
556
562
  continue;
557
563
  }
@@ -573,6 +579,7 @@ export function listAsyncRuns(asyncDirRoot: string, options: AsyncRunListOptions
573
579
  nestedRoute = resolveNestedRoute(status.runId || path.basename(asyncDir));
574
580
  if (nestedRoute) reconcileNestedAsyncDescendants(nestedRoute, { resultsDir: options.resultsDir, kill: options.kill, now: options.now });
575
581
  } catch (error) {
582
+ observeStatus?.(null);
576
583
  nestedWarnings.push(`Nested status unavailable: ${getErrorMessage(error)}`);
577
584
  }
578
585
  }
@@ -580,6 +587,7 @@ export function listAsyncRuns(asyncDirRoot: string, options: AsyncRunListOptions
580
587
  try {
581
588
  summary = statusToSummary(asyncDir, status, nestedWarnings, nestedRoute);
582
589
  } catch (error) {
590
+ observeStatus?.(null);
583
591
  if (!activeEntries.has(entry) || !isAsyncStatusIsolationError(asyncDir, error)) throw error;
584
592
  isolateCorruptActiveRun(asyncDir, entry, error, options.now);
585
593
  continue;
@@ -2,6 +2,7 @@ import type { AgentToolResult } from "@earendil-works/pi-agent-core";
2
2
  import { snapshotBackgroundWork } from "../../api/background-work.ts";
3
3
  import { DIRS, type Details, type SubagentState } from "../../shared/types.ts";
4
4
  import { listAsyncRuns } from "./async-status.ts";
5
+ import type { ReadonlyDrainObservation } from "../shared/readonly-drain-observation.ts";
5
6
  import { waitForSubagents, type SubagentWaitDeps, type SubagentWaitParams, type WaitEventBus } from "./subagent-wait.ts";
6
7
 
7
8
  export const DEFAULT_AUTO_DRAIN_TIMEOUT_MS = 30 * 60 * 1000;
@@ -23,46 +24,56 @@ function resultText(value: AgentToolResult<Details>): string {
23
24
  return value.content.map((part) => part.type === "text" ? part.text : "").join(" ").trim();
24
25
  }
25
26
 
26
- function hasOutstandingWork(sessionId: string, nowMs: number): boolean {
27
+ function hasOutstandingWork(sessionId: string, nowMs: number, observation?: ReadonlyDrainObservation): boolean {
27
28
  const asyncRuns = listAsyncRuns(DIRS.async, {
28
29
  states: ["queued", "running"],
29
30
  sessionId,
30
31
  resultsDir: DIRS.results,
31
32
  now: () => nowMs,
32
- });
33
+ }, observation?.status);
33
34
  return asyncRuns.length > 0 || snapshotBackgroundWork(sessionId, nowMs).items.length > 0;
34
35
  }
35
36
 
36
37
  /** Drain all work owned by the current headless session, including work added while draining. */
37
- export async function drainOutstandingWork(deps: AutoDrainDeps): Promise<void> {
38
+ export async function drainOutstandingWork(deps: AutoDrainDeps, observation?: ReadonlyDrainObservation): Promise<void> {
38
39
  const sessionId = deps.state.currentSessionId;
39
- if (!sessionId) throw new Error("Cannot auto-drain background work without an active session identity.");
40
- const now = deps.now ?? Date.now;
41
- const timeoutMs = deps.timeoutMs ?? DEFAULT_AUTO_DRAIN_TIMEOUT_MS;
42
- if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) throw new Error("Auto-drain timeoutMs must be a positive finite number.");
43
- const deadlineAt = now() + timeoutMs;
44
- const hasWork = deps.hasWork ?? hasOutstandingWork;
45
- const wait = deps.wait ?? waitForSubagents;
40
+ observation?.begin(sessionId, !deps.hasWork && !deps.wait && !deps.now);
41
+ try {
42
+ if (!sessionId) throw new Error("Cannot auto-drain background work without an active session identity.");
43
+ const now = deps.now ?? Date.now;
44
+ const timeoutMs = deps.timeoutMs ?? DEFAULT_AUTO_DRAIN_TIMEOUT_MS;
45
+ if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) throw new Error("Auto-drain timeoutMs must be a positive finite number.");
46
+ const deadlineAt = now() + timeoutMs;
47
+ const hasWork = deps.hasWork ?? (observation ? (id: string, time: number) => hasOutstandingWork(id, time, observation) : hasOutstandingWork);
48
+ const wait = deps.wait ?? waitForSubagents;
46
49
 
47
- while (hasWork(sessionId, now())) {
48
- const remainingMs = deadlineAt - now();
49
- if (remainingMs <= 0) {
50
- throw new Error(`Auto-drain timed out after ${timeoutMs}ms with background work still active in session '${sessionId}'.`);
51
- }
52
- const waitResult = await wait(
53
- { all: true, timeoutMs: remainingMs },
54
- undefined,
55
- {
56
- state: deps.state,
57
- events: deps.events,
58
- now,
59
- stopOnAttention: false,
60
- failOnFailedRuns: true,
61
- failOnAttention: true,
62
- },
63
- );
64
- if (waitResult.isError) {
65
- throw new Error(`Auto-drain failed for session '${sessionId}': ${resultText(waitResult) || "bg_wait returned an error without details"}.`);
50
+ while (true) {
51
+ const work = hasWork(sessionId, now());
52
+ observation?.predicate(work);
53
+ if (!work) break;
54
+ const remainingMs = deadlineAt - now();
55
+ if (remainingMs <= 0) {
56
+ throw new Error(`Auto-drain timed out after ${timeoutMs}ms with background work still active in session '${sessionId}'.`);
57
+ }
58
+ const waitResult = await wait(
59
+ { all: true, timeoutMs: remainingMs },
60
+ undefined,
61
+ {
62
+ state: deps.state,
63
+ events: deps.events,
64
+ now,
65
+ stopOnAttention: false,
66
+ failOnFailedRuns: true,
67
+ failOnAttention: true,
68
+ },
69
+ );
70
+ if (waitResult.isError) {
71
+ throw new Error(`Auto-drain failed for session '${sessionId}': ${resultText(waitResult) || "bg_wait returned an error without details"}.`);
72
+ }
66
73
  }
74
+ observation?.complete();
75
+ } catch (error) {
76
+ observation?.deny();
77
+ throw error;
67
78
  }
68
79
  }
@@ -32,6 +32,7 @@ export interface ImportedAsyncRootResult {
32
32
  structuredOutputSchemaPath?: string;
33
33
  acceptance?: AcceptanceLedger;
34
34
  artifactPaths?: ArtifactPaths;
35
+ savedOutputPath?: string;
35
36
  outputSaveError?: string;
36
37
  transcriptPath?: string;
37
38
  transcriptError?: string;
@@ -71,6 +72,7 @@ interface AsyncResultFile {
71
72
  structuredOutputSchemaPath?: string;
72
73
  acceptance?: AcceptanceLedger;
73
74
  artifactPaths?: ArtifactPaths;
75
+ savedOutputPath?: string;
74
76
  outputSaveError?: string;
75
77
  transcriptPath?: string;
76
78
  transcriptError?: string;
@@ -121,6 +123,11 @@ function selectedStatusStep(status: AsyncStatus | null, index: number): NonNulla
121
123
 
122
124
  function isTerminalStatus(status: AsyncStatus | null, index: number): boolean {
123
125
  if (!status) return false;
126
+ // A workflow-owned single runner publishes its result after completing its
127
+ // step. Keep that window open while root process proof is absent or pending;
128
+ // explicit terminal/unavailable proof retains the existing step fallback.
129
+ if (status.mode === "single" && status.parentWorkflowRunId
130
+ && (!status.processTerminal || status.processTerminal.state === "pending")) return TERMINAL_STATES.has(status.state);
124
131
  const step = selectedStatusStep(status, index);
125
132
  if (step && TERMINAL_STEP_STATUSES.has(step.status)) return true;
126
133
  return TERMINAL_STATES.has(status.state);
@@ -229,6 +236,7 @@ function buildImportedResult(root: ImportedAsyncRoot, status: AsyncStatus | null
229
236
  ...(child?.structuredOutputSchemaPath ?? step?.structuredOutputSchemaPath ? { structuredOutputSchemaPath: child?.structuredOutputSchemaPath ?? step?.structuredOutputSchemaPath } : {}),
230
237
  ...(child?.acceptance ?? step?.acceptance ? { acceptance: child?.acceptance ?? step?.acceptance } : {}),
231
238
  ...(child?.artifactPaths ? { artifactPaths: child.artifactPaths } : {}),
239
+ ...(child?.savedOutputPath ? { savedOutputPath: child.savedOutputPath } : {}),
232
240
  ...(child?.outputSaveError ? { outputSaveError: child.outputSaveError } : {}),
233
241
  ...(child?.transcriptPath ?? step?.transcriptPath ? { transcriptPath: child?.transcriptPath ?? step?.transcriptPath } : {}),
234
242
  ...(child?.transcriptError ? { transcriptError: child.transcriptError } : {}),