pi-subagents 0.67.0 → 0.68.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 (120) hide show
  1. package/CHANGELOG.md +70 -0
  2. package/README.md +1 -1
  3. package/docs/agents.md +37 -12
  4. package/docs/configuration.md +61 -19
  5. package/docs/extension-api.md +5 -1
  6. package/docs/missions.md +2 -2
  7. package/docs/models.md +11 -79
  8. package/docs/observability.md +18 -8
  9. package/docs/standalone-background.md +13 -3
  10. package/docs/tool-reference.md +15 -12
  11. package/docs/watchdog.md +10 -12
  12. package/docs/workflows.md +11 -1
  13. package/index.ts +5 -2
  14. package/package.json +4 -2
  15. package/runner-peer-loader.mjs +24 -0
  16. package/runner-peer-preload.mjs +25 -11
  17. package/skills/pi-subagents/SKILL.md +18 -21
  18. package/skills/pi-subagents/references/constraints-and-recipes.md +3 -2
  19. package/skills/pi-subagents/references/execution-controls.md +4 -4
  20. package/skills/pi-subagents/references/management-authoring-rpc.md +0 -1
  21. package/skills/pi-subagents/references/multi-lane-orchestration.md +1 -1
  22. package/skills/pi-subagents/references/prompting-and-roles.md +16 -12
  23. package/skills/pi-subagents/references/review-and-validation.md +3 -3
  24. package/src/agents/agent-management.ts +57 -58
  25. package/src/agents/agent-serializer.ts +4 -3
  26. package/src/agents/agents.ts +184 -71
  27. package/src/agents/chain-serializer.ts +5 -0
  28. package/src/agents/runtime-agent-registry.ts +7 -6
  29. package/src/api/preflight.ts +20 -16
  30. package/src/api/required-child-extensions.ts +6 -0
  31. package/src/extension/config.ts +10 -37
  32. package/src/extension/fanout-child.ts +3 -0
  33. package/src/extension/herdr-pi-bridge.ts +160 -0
  34. package/src/extension/index.ts +42 -31
  35. package/src/extension/public-execution.ts +3 -3
  36. package/src/extension/schemas.ts +16 -5
  37. package/src/extension/tool-description.ts +8 -7
  38. package/src/intercom/native-supervisor-channel.ts +22 -18
  39. package/src/policy/authority.ts +4 -0
  40. package/src/profiles/profiles.ts +12 -6
  41. package/src/runs/background/active-run-index.ts +17 -1
  42. package/src/runs/background/async-execution.ts +309 -126
  43. package/src/runs/background/async-job-tracker.ts +8 -6
  44. package/src/runs/background/async-resume.ts +13 -4
  45. package/src/runs/background/async-status.ts +15 -4
  46. package/src/runs/background/auto-drain.ts +20 -10
  47. package/src/runs/background/binary-bootstrap.ts +5 -0
  48. package/src/runs/background/chain-append.ts +1 -1
  49. package/src/runs/background/chain-root-attachment.ts +14 -33
  50. package/src/runs/background/notify.ts +74 -6
  51. package/src/runs/background/result-files.ts +8 -4
  52. package/src/runs/background/result-watcher.ts +19 -2
  53. package/src/runs/background/run-child-session.ts +20 -29
  54. package/src/runs/background/runner-aliases.ts +4 -33
  55. package/src/runs/background/runner-child-launch.ts +4 -1
  56. package/src/runs/background/runner-child-sessions.ts +2 -2
  57. package/src/runs/background/runner-http-dispatcher.ts +119 -0
  58. package/src/runs/background/scheduled-runs.ts +11 -5
  59. package/src/runs/background/stale-run-reconciler.ts +35 -11
  60. package/src/runs/background/subagent-runner.ts +396 -275
  61. package/src/runs/background/subagent-wait.ts +128 -23
  62. package/src/runs/background/wait-completions.ts +75 -27
  63. package/src/runs/background/wait-subscriptions.ts +9 -3
  64. package/src/runs/background/wait-tool.ts +4 -2
  65. package/src/runs/foreground/async-stop-action.ts +93 -3
  66. package/src/runs/foreground/execution.ts +91 -218
  67. package/src/runs/foreground/foreground-history.ts +2 -1
  68. package/src/runs/foreground/subagent-executor.ts +266 -80
  69. package/src/runs/shared/acceptance.ts +34 -10
  70. package/src/runs/shared/async-status-projection.ts +123 -33
  71. package/src/runs/shared/child-launch-plan.ts +15 -3
  72. package/src/runs/shared/child-launch.ts +19 -6
  73. package/src/runs/shared/child-runtime-config.ts +5 -0
  74. package/src/runs/shared/child-session.ts +94 -50
  75. package/src/runs/shared/child-tool-plan.ts +28 -16
  76. package/src/runs/shared/dynamic-fanout.ts +2 -2
  77. package/src/runs/shared/external-cli-contract.ts +11 -1
  78. package/src/runs/shared/external-cli-preflight.ts +6 -2
  79. package/src/runs/shared/herdr-connection.ts +134 -0
  80. package/src/runs/shared/herdr-external-adapters.ts +169 -0
  81. package/src/runs/shared/herdr-machine.ts +279 -0
  82. package/src/runs/shared/herdr-pi-protocol.ts +59 -0
  83. package/src/runs/shared/herdr-placed-run.ts +263 -0
  84. package/src/runs/shared/model-resolution-diagnostic.ts +76 -0
  85. package/src/runs/shared/{model-fallback.ts → model-resolution.ts} +22 -237
  86. package/src/runs/shared/model-scope.ts +1 -1
  87. package/src/runs/shared/nested-events.ts +11 -2
  88. package/src/runs/shared/parallel-utils.ts +7 -2
  89. package/src/runs/shared/pi-spawn.ts +1 -1
  90. package/src/runs/shared/subagent-prompt-runtime.ts +4 -2
  91. package/src/runs/shared/worktree-setup-command.ts +27 -4
  92. package/src/runs/shared/worktree.ts +3 -3
  93. package/src/shared/child-cache-retention.ts +43 -0
  94. package/src/shared/launch-contract.ts +6 -9
  95. package/src/shared/pruned-fork.ts +1 -1
  96. package/src/shared/required-child-extensions.ts +81 -0
  97. package/src/shared/settings.ts +5 -2
  98. package/src/shared/shortcuts.ts +0 -4
  99. package/src/shared/types.ts +70 -29
  100. package/src/slash/slash-commands.ts +0 -6
  101. package/src/slash/subagents-admin.ts +13 -9
  102. package/src/tui/render.ts +20 -10
  103. package/src/watchdog/child-status.ts +28 -36
  104. package/src/watchdog/lsp-diagnostics.ts +1 -1
  105. package/src/watchdog/model-selection.ts +1 -1
  106. package/src/watchdog/register-child.ts +10 -3
  107. package/src/watchdog/register-main.ts +20 -20
  108. package/src/watchdog/render.ts +1 -1
  109. package/src/watchdog/review.ts +14 -30
  110. package/src/watchdog/rules.ts +1 -1
  111. package/src/watchdog/runtime.ts +23 -12
  112. package/src/watchdog/settings.ts +3 -6
  113. package/src/watchdog/types.ts +3 -5
  114. package/src/watchdog/warning-format.ts +1 -1
  115. package/src/workflows/scripted-workflow.ts +42 -3
  116. package/src/workflows/workflow-receipt.ts +21 -3
  117. package/src/workflows/workflow-resources.ts +13 -2
  118. package/src/runs/shared/model-exclusions.ts +0 -374
  119. package/src/runs/shared/readonly-model-continuation.ts +0 -69
  120. package/src/runs/shared/readonly-session-evidence.ts +0 -307
@@ -39,6 +39,7 @@
39
39
  */
40
40
 
41
41
  import * as fs from "node:fs";
42
+ import * as path from "node:path";
42
43
  import type { AgentToolResult } from "@earendil-works/pi-agent-core";
43
44
  import {
44
45
  listBackgroundWorkWakeChannels,
@@ -61,6 +62,7 @@ import {
61
62
  type Usage,
62
63
  type WaitCompletion,
63
64
  } from "../../shared/types.ts";
65
+ import { nestedRunScope } from "../shared/nested-events.ts";
64
66
  import { formatDuration, shortenPath } from "../../shared/formatters.ts";
65
67
  import { toAgentToolUsage } from "../../shared/utils.ts";
66
68
  import { collectWaitCompletions } from "./wait-completions.ts";
@@ -103,6 +105,8 @@ export interface SubagentWaitDeps {
103
105
  onUpdate?: (result: AgentToolResult<Details>) => void;
104
106
  asyncDirRoot?: string;
105
107
  resultsDir?: string;
108
+ /** Root from the child's validated inherited route, not a tool argument. */
109
+ nestedRootRunId?: string;
106
110
  kill?: (pid: number, signal?: NodeJS.Signals | 0) => boolean;
107
111
  now?: () => number;
108
112
  pollIntervalMs?: number;
@@ -118,6 +122,8 @@ export interface SubagentWaitDeps {
118
122
  failOnFailedRuns?: boolean;
119
123
  /** Internal auto-drain mode surfaces actionable attention as an error. */
120
124
  failOnAttention?: boolean;
125
+ /** Durable owned supervisor-request barrier used by headless auto-drain. */
126
+ hasPendingSupervisorRequest?: () => boolean;
121
127
  /** Arm a durable exact-target wait subscription in a long-lived interactive runtime. */
122
128
  subscribe?: (input: { targetKind: "async" | "foreground"; runId: string; requestedId: string; timeoutMs: number }) => { token: string; expiresAt: number };
123
129
  /** Injectable provider protocol surfaces for deterministic tests. */
@@ -264,22 +270,39 @@ function backgroundWorkForSession(deps: SubagentWaitDeps, nowMs: number): Backgr
264
270
  return deps.backgroundWork?.snapshot(sessionId, nowMs) ?? snapshotBackgroundWork(sessionId, nowMs);
265
271
  }
266
272
 
267
- /** Queued/running runs from this session, including runs that need attention. */
268
- function activeRunsForSession(params: SubagentWaitParams, deps: SubagentWaitDeps): AsyncRunSummary[] {
269
- const asyncDirRoot = deps.asyncDirRoot ?? DIRS.async;
270
- const resultsDir = deps.resultsDir ?? DIRS.results;
271
- const runs = listAsyncRuns(asyncDirRoot, {
272
- states: [...ACTIVE_STATES],
273
+ export function waitRunScopes(deps: Pick<SubagentWaitDeps, "asyncDirRoot" | "resultsDir" | "nestedRootRunId">): Array<{ asyncDirRoot: string; resultsDir: string }> {
274
+ return [
275
+ { asyncDirRoot: deps.asyncDirRoot ?? DIRS.async, resultsDir: deps.resultsDir ?? DIRS.results },
276
+ ...(deps.nestedRootRunId ? [nestedRunScope(deps.nestedRootRunId)] : []),
277
+ ];
278
+ }
279
+
280
+ function collectScopedCompletions(terminal: AsyncRunSummary[], deps: SubagentWaitDeps, references: string[]): WaitCompletion[] | undefined {
281
+ const completions = waitRunScopes(deps).flatMap((scope) => collectWaitCompletions(
282
+ terminal.filter((run) => path.resolve(path.dirname(run.asyncDir)) === path.resolve(scope.asyncDirRoot)),
283
+ deps.state, scope.resultsDir, (reference) => references.push(reference),
284
+ ) ?? []);
285
+ return completions.length ? completions : undefined;
286
+ }
287
+
288
+ /** Immediate-session runs only; named lookups can also recover already-terminal results. */
289
+ function runsForSession(params: SubagentWaitParams, deps: SubagentWaitDeps, activeOnly = true): AsyncRunSummary[] {
290
+ const runs = waitRunScopes(deps).flatMap(({ asyncDirRoot, resultsDir }) => listAsyncRuns(asyncDirRoot, {
291
+ states: activeOnly ? [...ACTIVE_STATES] : undefined,
273
292
  sessionId: deps.state.currentSessionId ?? undefined,
274
293
  resultsDir,
275
294
  kill: deps.kill,
276
295
  now: deps.now,
277
296
  includeNested: false,
278
297
  ...(params.id ? { runId: params.id } : {}),
279
- });
298
+ }));
280
299
  return params.id ? runs.filter((run) => matchesId(run, params.id!)) : runs;
281
300
  }
282
301
 
302
+ function activeRunsForSession(params: SubagentWaitParams, deps: SubagentWaitDeps): AsyncRunSummary[] {
303
+ return runsForSession(params, deps);
304
+ }
305
+
283
306
  /** Runs (from the initial set) currently flagged needs_attention, for reporting. */
284
307
  function attentionRunsForSession(params: SubagentWaitParams, deps: SubagentWaitDeps, initialIds: Set<string>): AsyncRunSummary[] {
285
308
  return activeRunsForSession(params, deps).filter((run) => needsAttention(run) && initialIds.has(run.id));
@@ -287,9 +310,7 @@ function attentionRunsForSession(params: SubagentWaitParams, deps: SubagentWaitD
287
310
 
288
311
  /** Exact initial runs in any state, for the final summary. */
289
312
  function runsForIds(runIds: Iterable<string>, deps: SubagentWaitDeps): AsyncRunSummary[] {
290
- const asyncDirRoot = deps.asyncDirRoot ?? DIRS.async;
291
- const resultsDir = deps.resultsDir ?? DIRS.results;
292
- return [...runIds].flatMap((runId) => listAsyncRuns(asyncDirRoot, {
313
+ return waitRunScopes(deps).flatMap(({ asyncDirRoot, resultsDir }) => [...runIds].flatMap((runId) => listAsyncRuns(asyncDirRoot, {
293
314
  sessionId: deps.state.currentSessionId ?? undefined,
294
315
  resultsDir,
295
316
  kill: deps.kill,
@@ -297,7 +318,7 @@ function runsForIds(runIds: Iterable<string>, deps: SubagentWaitDeps): AsyncRunS
297
318
  includeNested: false,
298
319
  runId,
299
320
  exactRunId: true,
300
- }));
321
+ })));
301
322
  }
302
323
 
303
324
  function summarizeTerminalRuns(runs: AsyncRunSummary[], providerFinishedCount = 0): string {
@@ -377,6 +398,49 @@ function windowElapsedResult(
377
398
  };
378
399
  }
379
400
 
401
+ function supervisorYieldResult(
402
+ activeRunIds: string[],
403
+ activeProviderItems: readonly RegisteredBackgroundWorkItem[] = [],
404
+ ): AgentToolResult<Details> {
405
+ return {
406
+ content: [{ type: "text", text: "Wait yielded for a pending supervisor request. Background work remains active and will continue after the supervisor reply." }],
407
+ details: {
408
+ mode: "management",
409
+ results: [],
410
+ wait: {
411
+ reason: "supervisor_request",
412
+ timedOut: false,
413
+ activeRunIds,
414
+ activeProviderItems: activeProviderItems.map(({ provider, id }) => ({ provider, id })),
415
+ },
416
+ },
417
+ };
418
+ }
419
+
420
+ interface InitialWaitScope {
421
+ asyncRunIds: Set<string>;
422
+ foregroundRuns: Array<{ runId: string; sessionId?: string; detachedIndices: Set<number> }>;
423
+ providerIds: Set<string>;
424
+ }
425
+
426
+ function supervisorYieldForScope(scope: InitialWaitScope, params: SubagentWaitParams, deps: SubagentWaitDeps, nowMs: number): AgentToolResult<Details> {
427
+ try {
428
+ const activeAsyncIds = new Set(activeRunsForSession(params.id ? { id: params.id } : {}, deps).map((run) => run.id));
429
+ const activeRunIds = new Set([...scope.asyncRunIds].filter((id) => activeAsyncIds.has(id)));
430
+ for (const initial of scope.foregroundRuns) {
431
+ const current = deps.state.foregroundRuns?.get(initial.runId);
432
+ if (!current || current.sessionId !== initial.sessionId) continue;
433
+ if (current.children.some((child) => initial.detachedIndices.has(child.index) && child.status === "detached")) activeRunIds.add(initial.runId);
434
+ }
435
+ const activeProviderItems = scope.providerIds.size > 0
436
+ ? backgroundWorkForSession(deps, nowMs).items.filter((item) => scope.providerIds.has(backgroundWorkIdentity(item)))
437
+ : [];
438
+ return supervisorYieldResult([...activeRunIds], activeProviderItems);
439
+ } catch (error) {
440
+ return result(error instanceof Error ? error.message : String(error), true);
441
+ }
442
+ }
443
+
380
444
  /** Build the live status shown while async work keeps bg_wait blocked. */
381
445
  function asyncWaitUpdate(runs: AsyncRunSummary[], providerCount: number, elapsedMs: number): AgentToolResult<Details> {
382
446
  const activity = runs.flatMap((run) => {
@@ -499,6 +563,7 @@ async function waitForDetachedForegroundRun(
499
563
  now: () => number,
500
564
  pollIntervalMs: number,
501
565
  timeoutMs: number,
566
+ supervisorYield: () => AgentToolResult<Details>,
502
567
  ): Promise<AgentToolResult<Details>> {
503
568
  const initialDetachedIndices = new Set(run.children.filter((child) => child.status === "detached").map((child) => child.index));
504
569
  while (true) {
@@ -509,6 +574,7 @@ async function waitForDetachedForegroundRun(
509
574
  if (!current || current.sessionId !== run.sessionId) {
510
575
  return result(`Remembered foreground run "${run.runId}" disappeared before a terminal child result was recorded. Completion cannot be confirmed; do not launch a replacement without checking the originating child session.`, true);
511
576
  }
577
+ if (deps.hasPendingSupervisorRequest?.()) return supervisorYield();
512
578
  const pending = current.children.filter((child) => initialDetachedIndices.has(child.index) && child.status === "detached");
513
579
  const attention = foregroundChildrenNeedingAttention(current, initialDetachedIndices);
514
580
  if (attention.length > 0) return formatForegroundAttention(current, attention, now() - startedAt);
@@ -541,11 +607,13 @@ async function waitForSessionDetachedForegroundRuns(
541
607
  now: () => number,
542
608
  pollIntervalMs: number,
543
609
  timeoutMs: number,
610
+ supervisorYield: () => AgentToolResult<Details>,
544
611
  ): Promise<AgentToolResult<Details>> {
545
612
  const texts: string[] = [];
546
613
  for (const run of runs) {
547
- const one = await waitForDetachedForegroundRun(run, signal, deps, startedAt, now, pollIntervalMs, timeoutMs);
614
+ const one = await waitForDetachedForegroundRun(run, signal, deps, startedAt, now, pollIntervalMs, timeoutMs, supervisorYield);
548
615
  if (one.isError) return one;
616
+ if (one.details.wait?.reason === "supervisor_request") return one;
549
617
  if (one.details.wait?.reason === "window_elapsed") {
550
618
  const activeRunIds = runs.filter((initial) => {
551
619
  const current = deps.state.foregroundRuns?.get(initial.runId);
@@ -586,6 +654,7 @@ export async function waitForSubagents(
586
654
  ? params.timeoutMs
587
655
  : deps.defaultTimeoutMs ?? DEFAULT_TIMEOUT_MS;
588
656
  const startedAt = now();
657
+ const sessionId = deps.state.currentSessionId;
589
658
  const waitForAll = params.id ? true : params.all === true;
590
659
  if (params.nonBlocking && !params.id) {
591
660
  return result("Non-blocking wait subscriptions require id so the registration can bind one exact run identity.", true);
@@ -598,7 +667,7 @@ export async function waitForSubagents(
598
667
  let foreground: ForegroundResumeRun[];
599
668
  let providerSnapshot: BackgroundWorkSnapshot;
600
669
  try {
601
- active = activeRunsForSession(params, deps);
670
+ active = runsForSession(params, deps, !params.id);
602
671
  foreground = activeDetachedForegroundRuns(params, deps).map((run) => ({
603
672
  ...run,
604
673
  children: run.children.map((child) => ({ ...child })),
@@ -608,6 +677,7 @@ export async function waitForSubagents(
608
677
  return result(error instanceof Error ? error.message : String(error), true);
609
678
  }
610
679
 
680
+ let selectedForeground: ForegroundResumeRun | undefined;
611
681
  if (params.id) {
612
682
  const candidates = [
613
683
  ...active.map((run) => ({ kind: "async" as const, id: run.id, run })),
@@ -616,9 +686,21 @@ export async function waitForSubagents(
616
686
  const exact = candidates.filter((candidate) => candidate.id === params.id);
617
687
  const matches = exact.length > 0 ? exact : candidates;
618
688
  if (matches.length > 1) {
619
- return result(`Ambiguous subagent run id prefix "${params.id}" matched ${matches.length} active runs: ${matches.map((candidate) => candidate.id).join(", ")}. Pass a longer id.`, true);
689
+ return result(`Ambiguous subagent run id prefix "${params.id}" matched ${matches.length} runs: ${matches.map((candidate) => candidate.id).join(", ")}. Pass a longer id.`, true);
620
690
  }
621
691
  const selected = matches[0];
692
+ if (selected?.kind === "async" && !ACTIVE_STATES.includes(selected.run.state)) {
693
+ try {
694
+ const references: string[] = [];
695
+ const completions = collectScopedCompletions([selected.run], deps, references);
696
+ return result(
697
+ `Run "${selected.id}" is terminal. Outcome: ${summarizeTerminalRuns([selected.run])}.${formatCompletionRecovery(completions)}${references.length ? `\n${references.join("\n")}` : ""}`,
698
+ deps.failOnFailedRuns === true && (selected.run.state === "failed" || selected.run.state === "partial"), completions,
699
+ );
700
+ } catch (error) {
701
+ return result(error instanceof Error ? error.message : String(error), true);
702
+ }
703
+ }
622
704
  if (selected && params.nonBlocking) {
623
705
  if (!deps.subscribe) {
624
706
  return result("Non-blocking wait subscriptions require a long-lived interactive subagent runtime; this runtime can only use blocking bg_wait calls.", true);
@@ -630,30 +712,47 @@ export async function waitForSubagents(
630
712
  return result(error instanceof Error ? error.message : String(error), true);
631
713
  }
632
714
  }
633
- if (selected?.kind === "foreground") {
634
- return waitForDetachedForegroundRun(selected.run, signal, deps, startedAt, now, pollIntervalMs, timeoutMs);
635
- }
715
+ selectedForeground = selected?.kind === "foreground" ? selected.run : undefined;
636
716
  active = selected?.kind === "async" ? [selected.run] : [];
637
717
  }
638
718
 
639
719
  let providerActive = providerSnapshot.items;
720
+ const scopedForeground = selectedForeground ? [selectedForeground] : params.id ? [] : foreground;
721
+ const initialAsyncIds = new Set(active.map((run) => run.id));
722
+ const initialProviderIds = new Set(providerActive.map(backgroundWorkIdentity));
723
+ const initialScope: InitialWaitScope = {
724
+ asyncRunIds: initialAsyncIds,
725
+ foregroundRuns: scopedForeground.map((run) => ({
726
+ runId: run.runId,
727
+ sessionId: run.sessionId,
728
+ detachedIndices: new Set(run.children.filter((child) => child.status === "detached").map((child) => child.index)),
729
+ })),
730
+ providerIds: initialProviderIds,
731
+ };
732
+ const supervisorYield = () => supervisorYieldForScope(initialScope, params, deps, now());
733
+ if (selectedForeground) {
734
+ return waitForDetachedForegroundRun(selectedForeground, signal, deps, startedAt, now, pollIntervalMs, timeoutMs, supervisorYield);
735
+ }
640
736
  if (active.length === 0 && providerActive.length === 0) {
641
737
  if (waitForAll && !params.id && foreground.length > 0) {
642
- return waitForSessionDetachedForegroundRuns(foreground, signal, deps, startedAt, now, pollIntervalMs, timeoutMs);
738
+ return waitForSessionDetachedForegroundRuns(foreground, signal, deps, startedAt, now, pollIntervalMs, timeoutMs, supervisorYield);
643
739
  }
644
740
  return result(params.id
645
741
  ? `No active run matched "${params.id}". Nothing to wait for.`
646
742
  : "No active async runs or registered provider work in this session. Nothing to wait for.");
647
743
  }
648
744
  const waitParams = params.id ? { ...params, id: active[0]!.id } : params;
649
- const initialAsyncIds = new Set(active.map((run) => run.id));
650
- const initialProviderIds = new Set(providerActive.map(backgroundWorkIdentity));
651
745
  const initialProviderNames = new Set(providerActive.map((item) => item.provider));
652
746
  const initialCount = initialAsyncIds.size + initialProviderIds.size;
653
747
  const stopOnAttention = params.stopOnAttention ?? deps.stopOnAttention !== false;
654
748
  let attention = active.filter((run) => needsAttention(run));
749
+ let supervisorBarrier = false;
655
750
 
656
751
  const isDone = (): boolean => {
752
+ if (deps.hasPendingSupervisorRequest?.()) {
753
+ supervisorBarrier = true;
754
+ return true;
755
+ }
657
756
  if (attention.some((run) => initialAsyncIds.has(run.id) && (stopOnAttention || hasSupervisorTool(run)))) return true;
658
757
  const activeAsyncIds = new Set(active.map((run) => run.id));
659
758
  const activeProviderIds = new Set(providerActive.map(backgroundWorkIdentity));
@@ -685,6 +784,7 @@ export async function waitForSubagents(
685
784
  }
686
785
  try {
687
786
  await waitForWake(pollIntervalMs, signal, deps);
787
+ if (deps.state.currentSessionId !== sessionId) return result("Wait stopped because the active session changed.", true);
688
788
  active = activeRunsForSession(waitParams, deps);
689
789
  attention = attentionRunsForSession(waitParams, deps, initialAsyncIds);
690
790
  providerSnapshot = params.id ? providerSnapshot : backgroundWorkForSession(deps, now());
@@ -698,12 +798,16 @@ export async function waitForSubagents(
698
798
  return result(error instanceof Error ? error.message : String(error), true);
699
799
  }
700
800
  }
801
+ if (supervisorBarrier) {
802
+ return supervisorYield();
803
+ }
701
804
 
702
805
  let terminalSummary: string;
703
806
  let finishedAsyncCount: number;
704
807
  let failedAsyncCount: number;
705
808
  let completions: WaitCompletion[] | undefined;
706
809
  let resumeGuidance = "";
810
+ const references: string[] = [];
707
811
  const activeProviderIds = new Set(providerActive.map(backgroundWorkIdentity));
708
812
  const providerFinishedCount = [...initialProviderIds].filter((id) => !activeProviderIds.has(id)).length;
709
813
  try {
@@ -713,7 +817,7 @@ export async function waitForSubagents(
713
817
  failedAsyncCount = terminal.filter((run) => run.state === "failed" || run.state === "partial").length;
714
818
  terminalSummary = summarizeTerminalRuns(terminal, providerFinishedCount);
715
819
  resumeGuidance = formatResumeFirstFailedRunsNote(terminal);
716
- completions = collectWaitCompletions(terminal, deps.state, deps.resultsDir ?? DIRS.results);
820
+ completions = collectScopedCompletions(terminal, deps, references);
717
821
  } catch (error) {
718
822
  return result(error instanceof Error ? error.message : String(error), true);
719
823
  }
@@ -729,13 +833,14 @@ export async function waitForSubagents(
729
833
  + providerActive.filter((item) => initialProviderIds.has(backgroundWorkIdentity(item))).length;
730
834
  const elapsed = formatDuration(now() - startedAt);
731
835
  const outcome = terminalSummary ? ` Outcome: ${terminalSummary}.` : "";
732
- const recoveryNote = formatCompletionRecovery(completions);
836
+ const recoveryNote = formatCompletionRecovery(completions) + (references.length ? `\n${references.join("\n")}\n` : "");
733
837
 
734
838
  if (waitForAll) {
735
839
  const foregroundResult = !params.id && foreground.length > 0 && relevantAttention.length === 0
736
- ? await waitForSessionDetachedForegroundRuns(foreground, signal, deps, startedAt, now, pollIntervalMs, timeoutMs)
840
+ ? await waitForSessionDetachedForegroundRuns(foreground, signal, deps, startedAt, now, pollIntervalMs, timeoutMs, supervisorYield)
737
841
  : undefined;
738
842
  if (foregroundResult?.isError) return foregroundResult;
843
+ if (foregroundResult?.details.wait?.reason === "supervisor_request") return foregroundResult;
739
844
  if (foregroundResult?.details.wait?.reason === "window_elapsed") return foregroundResult;
740
845
  const foregroundNote = foregroundResult
741
846
  ? `\n${foregroundResult.content.map((part) => part.type === "text" ? part.text : "").join("\n")}`
@@ -2,7 +2,7 @@ import * as fs from "node:fs";
2
2
  import type { ArtifactPaths, SubagentState, Usage, WaitCompletion, WaitCompletionChild } from "../../shared/types.ts";
3
3
  import type { AsyncRunSummary } from "./async-status.ts";
4
4
  import { readCompletionReplay, writeCompletionReplay } from "./completion-replay.ts";
5
- import { fallbackResultPayloadPathForSessionRun, resultFilePath, resultPayloadPathForSessionRun } from "./result-files.ts";
5
+ import { fallbackResultPayloadPathForSessionRun, resultFilePath, resultPayloadMatchesSessionRun, resultPayloadPathForSessionRun } from "./result-files.ts";
6
6
  import { parseWorkflowChildSummary } from "../../workflows/workflow-child-summary.ts";
7
7
  import { projectTimeoutRecovery } from "../shared/mutation-evidence.ts";
8
8
 
@@ -44,6 +44,27 @@ function errorMessage(error: unknown): string {
44
44
 
45
45
  const STRUCTURED_OUTPUT_INLINE_LIMIT_BYTES = 4 * 1024;
46
46
 
47
+ function waitResultPayloadCandidate(resultsDir: string, run: AsyncRunSummary): string {
48
+ const publicPath = resultFilePath(resultsDir, run.id);
49
+ if (!run.sessionId) return publicPath;
50
+ try {
51
+ const indexedPath = resultPayloadPathForSessionRun(resultsDir, run.sessionId, run.id);
52
+ return indexedPath ?? publicPath;
53
+ } catch (error) {
54
+ if (!isAccessDenied(error)) throw error;
55
+ const pendingPath = fallbackResultPayloadPathForSessionRun(resultsDir, run.sessionId, run.id);
56
+ return pendingPath ?? publicPath;
57
+ }
58
+ }
59
+
60
+ function readWaitResultPayload(candidatePath: string, run: AsyncRunSummary): Record<string, unknown> | undefined {
61
+ // A terminal run without session ownership cannot safely claim a payload.
62
+ if (!run.sessionId) return undefined;
63
+ const payload: unknown = JSON.parse(fs.readFileSync(candidatePath, "utf-8"));
64
+ if (!resultPayloadMatchesSessionRun(payload, run.sessionId, run.id)) return undefined;
65
+ return payload as Record<string, unknown>;
66
+ }
67
+
47
68
  export function projectStructuredOutput(value: unknown): unknown {
48
69
  if (value === undefined) return undefined;
49
70
  const serialized = JSON.stringify(value);
@@ -118,8 +139,9 @@ export function toWaitCompletion(data: Record<string, unknown>, runId: string):
118
139
  /**
119
140
  * Record a consumed terminal payload for later surfacing by bg_wait, pruning
120
141
  * stale entries with the same TTL that dedupes completion notifications. The result
121
- * file is deleted after delivery, so this record is the only in-process source once
122
- * the watcher has consumed it.
142
+ * file is deleted after durable replay succeeds, so this record is the in-process
143
+ * source once the watcher has consumed it. Payload ownership must be explicit and
144
+ * agree with persistence ownership. Returns whether that replay is durable.
123
145
  */
124
146
  export function recordWaitCompletion(
125
147
  state: SubagentState,
@@ -128,7 +150,10 @@ export function recordWaitCompletion(
128
150
  now: number,
129
151
  ttlMs: number,
130
152
  persistence?: { resultsDir: string; sessionId: string },
131
- ): void {
153
+ ): boolean {
154
+ const sessionId = asNonEmptyString(data.sessionId);
155
+ if (!sessionId || !resultPayloadMatchesSessionRun(data, sessionId, runId)) return false;
156
+ if (persistence && persistence.sessionId !== sessionId) return false;
132
157
  const store = state.completedResults ??= new Map();
133
158
  for (const [key, entry] of store) {
134
159
  if (now - entry.seenAt > ttlMs) store.delete(key);
@@ -148,7 +173,8 @@ export function recordWaitCompletion(
148
173
  console.error(`Failed to persist completion replay for '${runId}':`, error);
149
174
  }
150
175
  }
151
- store.set(runId, { seenAt: now, completion });
176
+ store.set(runId, { sessionId, seenAt: now, completion });
177
+ return completion.archivePath !== undefined;
152
178
  }
153
179
 
154
180
  /**
@@ -157,37 +183,59 @@ export function recordWaitCompletion(
157
183
  * so a direct read never observes a torn write; the read is deliberately read-only —
158
184
  * the watcher owns notification and cleanup.
159
185
  */
160
- export function collectWaitCompletions(terminal: AsyncRunSummary[], state: SubagentState, resultsDir: string): WaitCompletion[] | undefined {
186
+ export function collectWaitCompletions(terminal: AsyncRunSummary[], state: SubagentState, resultsDir: string, onReference?: (text: string) => void): WaitCompletion[] | undefined {
161
187
  if (terminal.length === 0) return undefined;
162
188
  const completions: WaitCompletion[] = [];
189
+ const add = (completion: WaitCompletion, resultPath?: string): void => {
190
+ completions.push(completion);
191
+ // Tool details are not model context. Publish the evidence address in content
192
+ // without copying potentially unbounded reviewer output into every wait.
193
+ const reference = completion.archivePath ?? resultPath;
194
+ if (reference) onReference?.(`Result [${completion.runId}]: ${reference}`);
195
+ };
163
196
  for (const run of terminal) {
164
197
  const recorded = state.completedResults?.get(run.id);
165
- if (recorded) {
166
- completions.push(recorded.completion);
198
+ if (recorded && recorded.sessionId === run.sessionId) {
199
+ if (recorded.completion.archivePath) {
200
+ add(recorded.completion);
201
+ continue;
202
+ }
203
+ try {
204
+ const candidatePath = waitResultPayloadCandidate(resultsDir, run);
205
+ if (!readWaitResultPayload(candidatePath, run)) {
206
+ const replay = readCompletionReplay(resultsDir, run.id, { sessionId: run.sessionId });
207
+ add(replay?.completion ?? recorded.completion);
208
+ continue;
209
+ }
210
+ add(recorded.completion, candidatePath);
211
+ continue;
212
+ } catch (error) {
213
+ if (errorCode(error) !== "ENOENT") throw error;
214
+ }
215
+ const replay = readCompletionReplay(resultsDir, run.id, { sessionId: run.sessionId });
216
+ add(replay?.completion ?? recorded.completion);
167
217
  continue;
168
218
  }
169
- const publicResultPath = resultFilePath(resultsDir, run.id);
170
- let resultPath = publicResultPath;
219
+ let candidatePath: string;
171
220
  try {
172
- resultPath = run.sessionId
173
- ? resultPayloadPathForSessionRun(resultsDir, run.sessionId, run.id) ?? publicResultPath
174
- : publicResultPath;
221
+ candidatePath = waitResultPayloadCandidate(resultsDir, run);
175
222
  } catch (error) {
176
- if (!isAccessDenied(error) || !run.sessionId) throw error;
177
- try {
178
- resultPath = fallbackResultPayloadPathForSessionRun(resultsDir, run.sessionId, run.id) ?? publicResultPath;
179
- } catch (fallbackError) {
180
- throw new Error(`Failed to read subagent result '${publicResultPath}': ${errorMessage(fallbackError)}`, {
181
- cause: fallbackError instanceof Error ? fallbackError : undefined,
182
- });
183
- }
223
+ const publicResultPath = resultFilePath(resultsDir, run.id);
224
+ throw new Error(`Failed to read subagent result '${publicResultPath}': ${errorMessage(error)}`, {
225
+ cause: error instanceof Error ? error : undefined,
226
+ });
184
227
  }
185
228
  try {
186
- const raw = JSON.parse(fs.readFileSync(resultPath, "utf-8")) as Record<string, unknown>;
187
- completions.push(toWaitCompletion(raw, run.id));
229
+ const payload = readWaitResultPayload(candidatePath, run);
230
+ if (!payload) {
231
+ const replay = readCompletionReplay(resultsDir, run.id, { sessionId: run.sessionId });
232
+ if (replay) add(replay.completion);
233
+ continue;
234
+ }
235
+ add(toWaitCompletion(payload, run.id), candidatePath);
188
236
  } catch (error) {
189
237
  if (errorCode(error) !== "ENOENT") {
190
- throw new Error(`Failed to read subagent result '${resultPath}': ${errorMessage(error)}`, {
238
+ throw new Error(`Failed to read subagent result '${candidatePath}': ${errorMessage(error)}`, {
191
239
  cause: error instanceof Error ? error : undefined,
192
240
  });
193
241
  }
@@ -195,13 +243,13 @@ export function collectWaitCompletions(terminal: AsyncRunSummary[], state: Subag
195
243
  // read. Prefer its in-memory record, then the durable replay written before
196
244
  // result cleanup so watcher reloads do not lose completion details.
197
245
  const late = state.completedResults?.get(run.id);
198
- if (late) {
199
- completions.push(late.completion);
246
+ if (late && late.sessionId === run.sessionId) {
247
+ add(late.completion);
200
248
  continue;
201
249
  }
202
250
  try {
203
251
  const replay = readCompletionReplay(resultsDir, run.id, { sessionId: run.sessionId });
204
- if (replay) completions.push(replay.completion);
252
+ if (replay) add(replay.completion);
205
253
  } catch (replayError) {
206
254
  throw new Error(`Failed to read completion replay for '${run.id}': ${errorMessage(replayError)}`, {
207
255
  cause: replayError instanceof Error ? replayError : undefined,
@@ -44,6 +44,7 @@ export interface ArmWaitSubscriptionInput {
44
44
  }
45
45
 
46
46
  export interface WaitSubscriptionManager {
47
+ start(): void;
47
48
  arm(input: ArmWaitSubscriptionInput): WaitSubscriptionRecord;
48
49
  restore(): void;
49
50
  reconcile(): void;
@@ -109,6 +110,7 @@ export function createWaitSubscriptionManager(
109
110
  state.waitSubscriptions = subscriptions;
110
111
  const unresolvedRestoredForegroundTokens = new Set<string>();
111
112
  let disposed = false;
113
+ let interval: ReturnType<typeof setInterval> | undefined;
112
114
  let lastForeignSweepAt = 0;
113
115
 
114
116
  /**
@@ -283,10 +285,13 @@ export function createWaitSubscriptionManager(
283
285
  SUBAGENT_RESULT_INTERCOM_EVENT,
284
286
  ];
285
287
  const unsubscribes = wakeChannels.map((channel) => pi.events.on(channel, reconcile));
286
- const interval = setInterval(reconcile, options.pollIntervalMs ?? RECONCILE_INTERVAL_MS);
287
- interval.unref?.();
288
288
 
289
289
  return {
290
+ start() {
291
+ if (disposed || interval) return;
292
+ interval = setInterval(reconcile, options.pollIntervalMs ?? RECONCILE_INTERVAL_MS);
293
+ interval.unref?.();
294
+ },
290
295
  arm(input) {
291
296
  const sessionId = state.currentSessionId;
292
297
  if (!sessionId) throw new Error("A wait subscription requires an active session identity.");
@@ -338,7 +343,8 @@ export function createWaitSubscriptionManager(
338
343
  dispose() {
339
344
  if (disposed) return;
340
345
  disposed = true;
341
- clearInterval(interval);
346
+ if (interval) clearInterval(interval);
347
+ interval = undefined;
342
348
  for (const unsubscribe of unsubscribes) {
343
349
  try { unsubscribe(); } catch { /* best effort */ }
344
350
  }
@@ -11,14 +11,15 @@ export function registerWaitTool(
11
11
  enabled = resolveWaitToolConfig().enabled,
12
12
  subscriptions?: Pick<WaitSubscriptionManager, "arm">,
13
13
  defaultTimeoutMs?: number,
14
+ child?: { nestedRootRunId?: string },
14
15
  ): void {
15
16
  const description = `Wait for background, provider, or detached work that has no native completion notification, then return.
16
17
 
17
- Ordinary async subagent runs already notify this session natively when they complete or need attention. In an interactive chat, return control instead of calling this merely to wait. Use this tool for provider jobs, remembered detached foreground runs, or other background work without a native notification path. Headless runs auto-drain current-session subagent work at agent_end; use this tool only when the current turn must receive non-notifying background work results.
18
+ ${child ? "This child runtime does not install the root session's native completion notifier. Use blocking bg_wait to collect your owned descendants during this turn, then read the returned result references before synthesizing findings. Automatic draining at agent_end keeps owned work alive but does not synthesize its results." : "Ordinary async subagent runs already notify this session natively when they complete or need attention. In an interactive chat, return control instead of calling this merely to wait. Use this tool for provider jobs, remembered detached foreground runs, or other background work without a native notification path. Headless runs auto-drain current-session subagent work at agent_end; use this tool only when the current turn must receive non-notifying background work results."}
18
19
 
19
20
  • { } — return when the first initially active async run or registered provider item finishes, or when a subagent needs attention.
20
21
  • { all: true } — wait for every async run, provider item, and remembered detached foreground descendant that was active when the call began.
21
- • { id: "..." } — wait for one async or remembered detached foreground subagent run (id or prefix).
22
+ • { id: "..." } — wait for one async or remembered detached foreground subagent run (id or prefix). Named async runs that already finished return their terminal result references.
22
23
  • { id: "...", nonBlocking: true } — resolve the prefix once, persist an exact-run wake subscription, and return immediately. Use this for detached work without native completion delivery; the originating interactive session wakes on completion, failure, attention, reconciliation failure, or timeout.
23
24
  • { stopOnAttention: false } — for blocking waits only, keep waiting through idle or long-thinking attention; supervisor/contact requests still stop the wait.
24
25
  • { timeoutMs: 600000 } — stop waiting after N ms; active work keeps running. Omitted values use waitTool.defaultTimeoutMs, then 30 minutes. Window expiry returns a non-error window_elapsed result with active work identities.
@@ -26,6 +27,7 @@ Ordinary async subagent runs already notify this session natively when they comp
26
27
  Non-blocking subscriptions are visible in subagent status and differ from disabling waitTool: waitTool.enabled=false returns immediately without registering any future wake. Provider jobs are session-scoped and identified exactly, so replacing one job with another cannot hide a completion. Provider extensions must be explicitly loaded in this process. In a child agent, keep \`bg_wait\` in the child tool allowlist and load each provider through the agent's extensions or subagentOnlyExtensions; this tool never loads providers or grants tools itself.${enabled ? "" : "\n\nConfigured behavior: bg_wait is disabled by config.waitTool or PI_SUBAGENT_WAIT_TOOL_ENABLED and returns immediately without blocking."}`;
27
28
  const execute: ToolDefinition<typeof SubagentWaitParams, Details>["execute"] = async (_id, params, signal, onUpdate, ctx) => finalizeToolResult(await waitForSubagents(params, signal, {
28
29
  state,
30
+ nestedRootRunId: child?.nestedRootRunId,
29
31
  events: pi.events,
30
32
  enabled,
31
33
  ...(defaultTimeoutMs !== undefined ? { defaultTimeoutMs } : {}),