pi-subagents 0.47.0 → 0.48.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 +37 -0
  2. package/README.md +2 -0
  3. package/agents/reviewer.md +3 -4
  4. package/docs/configuration.md +46 -2
  5. package/docs/observability.md +1 -1
  6. package/docs/tool-reference.md +1 -1
  7. package/package.json +1 -1
  8. package/src/agents/agents.ts +9 -3
  9. package/src/extension/config.ts +16 -0
  10. package/src/extension/doctor.ts +40 -0
  11. package/src/extension/index.ts +30 -11
  12. package/src/extension/public-execution.ts +5 -0
  13. package/src/extension/rpc.ts +7 -9
  14. package/src/extension/schemas.ts +4 -4
  15. package/src/intercom/intercom-bridge.ts +4 -1
  16. package/src/intercom/native-supervisor-channel.ts +102 -4
  17. package/src/missions/lifecycle.ts +4 -7
  18. package/src/missions/store.ts +4 -4
  19. package/src/missions/workflow-state.ts +6 -2
  20. package/src/runs/background/active-async-capacity.ts +374 -0
  21. package/src/runs/background/active-run-index.ts +46 -0
  22. package/src/runs/background/async-execution.ts +117 -29
  23. package/src/runs/background/async-job-tracker.ts +279 -134
  24. package/src/runs/background/async-resume.ts +7 -1
  25. package/src/runs/background/async-status.ts +33 -4
  26. package/src/runs/background/chain-append.ts +33 -15
  27. package/src/runs/background/control-channel.ts +55 -17
  28. package/src/runs/background/owned-process-tree.ts +104 -0
  29. package/src/runs/background/process-terminal.ts +17 -3
  30. package/src/runs/background/result-watcher.ts +87 -6
  31. package/src/runs/background/run-status.ts +23 -1
  32. package/src/runs/background/scheduled-runs.ts +23 -0
  33. package/src/runs/background/stale-run-reconciler.ts +10 -3
  34. package/src/runs/background/subagent-runner.ts +75 -33
  35. package/src/runs/foreground/async-dismiss-action.ts +85 -0
  36. package/src/runs/foreground/async-steering-action.ts +6 -7
  37. package/src/runs/foreground/chain-execution.ts +37 -2
  38. package/src/runs/foreground/execution.ts +90 -19
  39. package/src/runs/foreground/foreground-control.ts +12 -0
  40. package/src/runs/foreground/prompt-audit.ts +171 -0
  41. package/src/runs/foreground/subagent-executor.ts +648 -195
  42. package/src/runs/shared/acceptance.ts +13 -4
  43. package/src/runs/shared/llm-intent-arbiter.ts +286 -0
  44. package/src/runs/shared/parallel-utils.ts +2 -0
  45. package/src/runs/shared/pi-args.ts +44 -1
  46. package/src/runs/shared/run-fanout-budget.ts +280 -0
  47. package/src/runs/shared/single-output.ts +4 -2
  48. package/src/runs/shared/subagent-prompt-runtime.ts +24 -5
  49. package/src/runs/shared/task-intent.ts +19 -3
  50. package/src/runs/shared/worktree.ts +17 -5
  51. package/src/shared/artifacts.ts +1 -1
  52. package/src/shared/file-coalescer.ts +9 -0
  53. package/src/shared/types.ts +97 -1
  54. package/src/shared/utils.ts +3 -1
  55. package/src/tui/fleet-status.ts +7 -5
  56. package/src/tui/fleet.ts +225 -12
  57. package/src/workflows/scripted-workflow.ts +26 -6
@@ -4,7 +4,7 @@ import * as path from "node:path";
4
4
  import { writeAtomicJson } from "../../shared/atomic-json.ts";
5
5
  import { appendJsonl } from "../../shared/artifacts.ts";
6
6
  import type { AsyncParallelGroupStatus, AsyncStatus, WorkflowGraphNode, WorkflowGraphSnapshot } from "../../shared/types.ts";
7
- import { readStatus } from "../../shared/utils.ts";
7
+ import { PROMPT_REDACTED, readStatus } from "../../shared/utils.ts";
8
8
  import type { DynamicRunnerGroup, ParallelStepGroup, RunnerStep, RunnerSubagentStep, RunnerCheckpointStep } from "../shared/parallel-utils.ts";
9
9
  import { isCheckpointRunnerStep, isDynamicRunnerGroup, isParallelGroup } from "../shared/parallel-utils.ts";
10
10
 
@@ -19,6 +19,7 @@ export interface ChainAppendRequest {
19
19
  export interface ChainAppendResult {
20
20
  request: ChainAppendRequest;
21
21
  pendingCount: number;
22
+ bookkeepingError?: string;
22
23
  }
23
24
 
24
25
  type StatusStep = NonNullable<AsyncStatus["steps"]>[number];
@@ -69,6 +70,7 @@ export function enqueueChainAppendRequest(input: {
69
70
  runId: string;
70
71
  steps: RunnerStep[];
71
72
  now?: number;
73
+ admit?: (persist: () => void) => void;
72
74
  }): ChainAppendResult {
73
75
  const status = readStatus(input.asyncDir);
74
76
  if (!status) throw new Error(`No async run status found for '${input.runId}'.`);
@@ -85,20 +87,36 @@ export function enqueueChainAppendRequest(input: {
85
87
  steps: input.steps,
86
88
  };
87
89
  fs.mkdirSync(appendDir(input.asyncDir), { recursive: true });
88
- writeAtomicJson(appendRequestPath(input.asyncDir, request), request);
89
- const pendingCount = countPendingChainAppendRequests(input.asyncDir);
90
+ const persist = () => writeAtomicJson(appendRequestPath(input.asyncDir, request), request);
91
+ if (input.admit) input.admit(persist);
92
+ else persist();
93
+ let pendingCount = 1;
94
+ const bookkeepingErrors: string[] = [];
95
+ try {
96
+ pendingCount = countPendingChainAppendRequests(input.asyncDir);
97
+ } catch (error) {
98
+ bookkeepingErrors.push(`pending append count failed: ${error instanceof Error ? error.message : String(error)}`);
99
+ }
90
100
  const statusPath = path.join(input.asyncDir, "status.json");
91
101
  const updatedStatus = { ...status, pendingAppends: pendingCount, lastUpdate: request.createdAt };
92
- writeAtomicJson(statusPath, updatedStatus);
93
- appendJsonl(path.join(input.asyncDir, "events.jsonl"), JSON.stringify({
94
- type: "subagent.chain.append.requested",
95
- ts: request.createdAt,
96
- runId: input.runId,
97
- requestId: request.id,
98
- stepCount: input.steps.length,
99
- pendingAppends: pendingCount,
100
- }));
101
- return { request, pendingCount };
102
+ try {
103
+ writeAtomicJson(statusPath, updatedStatus);
104
+ } catch (error) {
105
+ bookkeepingErrors.push(`status update failed: ${error instanceof Error ? error.message : String(error)}`);
106
+ }
107
+ try {
108
+ appendJsonl(path.join(input.asyncDir, "events.jsonl"), JSON.stringify({
109
+ type: "subagent.chain.append.requested",
110
+ ts: request.createdAt,
111
+ runId: input.runId,
112
+ requestId: request.id,
113
+ stepCount: input.steps.length,
114
+ pendingAppends: pendingCount,
115
+ }));
116
+ } catch (error) {
117
+ bookkeepingErrors.push(`event append failed: ${error instanceof Error ? error.message : String(error)}`);
118
+ }
119
+ return { request, pendingCount, ...(bookkeepingErrors.length > 0 ? { bookkeepingError: bookkeepingErrors.join("; ") } : {}) };
102
120
  }
103
121
 
104
122
  function readAppendRequest(filePath: string): ChainAppendRequest | undefined {
@@ -135,8 +153,8 @@ const MAX_STATUS_STEP_DESCRIPTION_CHARS = 160;
135
153
 
136
154
  /** Bounded one-line per-step task description persisted into status.json for fleet display. */
137
155
  export function statusStepDescription(task: string | undefined): string | undefined {
138
- const description = task?.replace(/\s+/g, " ").trim();
139
- if (!description) return undefined;
156
+ if (!task?.trim()) return undefined;
157
+ const description = PROMPT_REDACTED;
140
158
  return description.length > MAX_STATUS_STEP_DESCRIPTION_CHARS
141
159
  ? `${description.slice(0, MAX_STATUS_STEP_DESCRIPTION_CHARS - 1)}…`
142
160
  : description;
@@ -32,6 +32,7 @@ function writeJsonToExistingDir(filePath: string, payload: object): void {
32
32
  }
33
33
  }
34
34
  export type ControlChannelTimers = { setInterval: typeof setInterval; clearInterval: typeof clearInterval };
35
+ const CONTROL_SAFETY_POLL_INTERVAL_MS = 5000;
35
36
  type KillFn = (pid: number, signal?: NodeJS.Signals | 0) => unknown;
36
37
 
37
38
  export interface InterruptRequest {
@@ -621,9 +622,9 @@ export function deliverCheckpointDecisionRequest(input: {
621
622
 
622
623
  /**
623
624
  * Runner side: watch the control inbox and route interrupt requests into
624
- * `onInterrupt`. Uses `fs.watch` when available plus an interval poll as a
625
- * portable safety net (covers filesystems/platforms where `fs.watch` is
626
- * unreliable). Fires once per distinct request. Returns a disposer.
625
+ * `onInterrupt`. Uses `fs.watch` when available and starts interval polling
626
+ * only when native watching is unavailable or fails. Fires once per distinct
627
+ * request. Returns a disposer.
627
628
  */
628
629
  export function watchAsyncControlInbox(
629
630
  asyncDir: string,
@@ -636,6 +637,7 @@ export function watchAsyncControlInbox(
636
637
  onSteerCapability?: (capability: SteerCapability) => void;
637
638
  onSteerAck?: (ack: SteerAck) => void;
638
639
  pollIntervalMs?: number;
640
+ safetyPollIntervalMs?: number;
639
641
  fs?: ControlChannelFs;
640
642
  timers?: ControlChannelTimers;
641
643
  },
@@ -669,27 +671,63 @@ export function watchAsyncControlInbox(
669
671
  // Handle a request that may have arrived before the watcher started.
670
672
  check();
671
673
 
672
- let watcher: fs.FSWatcher | undefined;
673
- try {
674
- watcher = fsImpl.watch(resolveWatchPath(dir, fsImpl.realpathSync.native), () => check());
675
- watcher.on?.("error", () => {
676
- // fs.watch can emit on transient FS errors; the interval poll keeps us live.
674
+ const watchers: fs.FSWatcher[] = [];
675
+ const watchedDirs = new Set<string>();
676
+ let interval: ReturnType<typeof setInterval> | undefined;
677
+ let safetyInterval: ReturnType<typeof setInterval> | undefined;
678
+ const startPolling = (): void => {
679
+ if (interval || disposed) return;
680
+ interval = timers.setInterval(check, opts.pollIntervalMs ?? POLL_INTERVAL_MS);
681
+ interval.unref?.();
682
+ };
683
+ const startSafetyPolling = (): void => {
684
+ if (safetyInterval || disposed) return;
685
+ safetyInterval = timers.setInterval(check, opts.safetyPollIntervalMs ?? CONTROL_SAFETY_POLL_INTERVAL_MS);
686
+ safetyInterval.unref?.();
687
+ };
688
+ const watchDir = (target: string, create = false): void => {
689
+ if (disposed || watchedDirs.has(target)) return;
690
+ if (create) fsImpl.mkdirSync(target, { recursive: true });
691
+ const watcher = fsImpl.watch(resolveWatchPath(target, fsImpl.realpathSync.native), () => {
692
+ watchExistingSteerAckDirs();
693
+ check();
677
694
  });
695
+ watcher.on?.("error", startPolling);
696
+ watchers.push(watcher);
697
+ watchedDirs.add(target);
698
+ };
699
+ const watchExistingSteerAckDirs = (): void => {
700
+ const ackRoot = path.join(dir, STEER_ACKS_DIR);
701
+ let entries: string[];
702
+ try {
703
+ entries = fsImpl.readdirSync(ackRoot).filter((name) => /^\d+$/.test(name));
704
+ } catch {
705
+ return;
706
+ }
707
+ for (const entry of entries) watchDir(path.join(ackRoot, entry));
708
+ };
709
+ try {
710
+ watchDir(dir);
711
+ watchDir(steerRequestsDir(asyncDir), true);
712
+ watchDir(steerCapabilitiesDir(asyncDir), true);
713
+ watchDir(path.join(dir, STEER_ACKS_DIR), true);
714
+ watchExistingSteerAckDirs();
715
+ startSafetyPolling();
678
716
  } catch {
679
- watcher = undefined;
717
+ startPolling();
680
718
  }
681
719
 
682
- const interval = timers.setInterval(check, opts.pollIntervalMs ?? POLL_INTERVAL_MS);
683
- interval.unref?.();
684
-
685
720
  return () => {
686
721
  if (disposed) return;
687
722
  disposed = true;
688
- try {
689
- watcher?.close();
690
- } catch {
691
- // ignore
723
+ for (const watcher of watchers) {
724
+ try {
725
+ watcher.close();
726
+ } catch {
727
+ // ignore
728
+ }
692
729
  }
693
- timers.clearInterval(interval);
730
+ if (interval) timers.clearInterval(interval);
731
+ if (safetyInterval) timers.clearInterval(safetyInterval);
694
732
  };
695
733
  }
@@ -0,0 +1,104 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import type { ProcessTreeTerminalV1 } from "../../shared/types.ts";
3
+
4
+ const DEFAULT_TERM_GRACE_MS = 3000;
5
+ const DEFAULT_KILL_VERIFY_MS = 1000;
6
+ const VERIFY_INTERVAL_MS = 25;
7
+
8
+ type SignalResult = "sent" | "absent" | { diagnostic: string };
9
+
10
+ function diagnostic(error: unknown): string {
11
+ return error instanceof Error ? error.message : String(error);
12
+ }
13
+
14
+ function signalProcess(id: number, signal: NodeJS.Signals): SignalResult {
15
+ try {
16
+ process.kill(id, signal);
17
+ return "sent";
18
+ } catch (error) {
19
+ if ((error as NodeJS.ErrnoException).code === "ESRCH") return "absent";
20
+ return { diagnostic: diagnostic(error) };
21
+ }
22
+ }
23
+
24
+ function activeProcessGroupMembers(processGroupId: number): number[] | { diagnostic: string } {
25
+ const result = spawnSync("ps", ["-axo", "pid=,pgid=,stat="], { encoding: "utf-8" });
26
+ if (result.error || result.status !== 0) {
27
+ return { diagnostic: result.error ? diagnostic(result.error) : (result.stderr.trim() || `ps exited with ${result.status}`) };
28
+ }
29
+ const members: number[] = [];
30
+ for (const line of result.stdout.split("\n")) {
31
+ const match = /^\s*(\d+)\s+(\d+)\s+(\S+)/.exec(line);
32
+ if (!match || Number(match[2]) !== processGroupId || match[3]!.startsWith("Z")) continue;
33
+ members.push(Number(match[1]));
34
+ }
35
+ return members;
36
+ }
37
+
38
+ async function waitUntilGroupTerminal(
39
+ processGroupId: number,
40
+ timeoutMs: number,
41
+ ): Promise<false | { state: "enumeration-failed" | "still-active"; diagnostic: string }> {
42
+ const deadline = Date.now() + timeoutMs;
43
+ while (true) {
44
+ const members = activeProcessGroupMembers(processGroupId);
45
+ if (Array.isArray(members) && members.length === 0) return false;
46
+ const remaining = deadline - Date.now();
47
+ if (remaining <= 0) {
48
+ if (!Array.isArray(members)) return { state: "enumeration-failed", diagnostic: members.diagnostic };
49
+ return { state: "still-active", diagnostic: `Process group ${processGroupId} still has active members: ${members.join(", ")}.` };
50
+ }
51
+ await new Promise<void>((resolve) => setTimeout(resolve, Math.min(VERIFY_INTERVAL_MS, remaining)));
52
+ }
53
+ }
54
+
55
+ function observed(processGroupId: number): ProcessTreeTerminalV1 {
56
+ return { state: "observed", mechanism: "posix-process-group", processGroupId, verifiedAt: Date.now() };
57
+ }
58
+
59
+ /** Owns one writer process group and arbitrates its cleanup exactly once. */
60
+ export interface OwnedProcessTreeController {
61
+ terminate(): Promise<ProcessTreeTerminalV1>;
62
+ finishAfterWriterClose(): Promise<ProcessTreeTerminalV1>;
63
+ }
64
+
65
+ export function createOwnedProcessTreeController(
66
+ pid: number,
67
+ options: { termGraceMs?: number; killVerifyMs?: number } = {},
68
+ ): OwnedProcessTreeController {
69
+ let termination: Promise<ProcessTreeTerminalV1> | undefined;
70
+ const posixGroupOwned = process.platform !== "win32";
71
+ const target = posixGroupOwned ? -pid : pid;
72
+
73
+ const terminate = (): Promise<ProcessTreeTerminalV1> => {
74
+ if (termination) return termination;
75
+ termination = (async () => {
76
+ if (!posixGroupOwned) {
77
+ signalProcess(target, "SIGTERM");
78
+ return { state: "unknown", reason: "unsupported-platform" };
79
+ }
80
+ const term = signalProcess(target, "SIGTERM");
81
+ if (term !== "sent" && term !== "absent") {
82
+ return { state: "unknown", reason: "signal-failed", diagnostic: term.diagnostic };
83
+ }
84
+ const termExit = await waitUntilGroupTerminal(pid, options.termGraceMs ?? DEFAULT_TERM_GRACE_MS);
85
+ if (termExit === false) return observed(pid);
86
+
87
+ const kill = signalProcess(target, "SIGKILL");
88
+ if (kill !== "sent" && kill !== "absent") {
89
+ const members = activeProcessGroupMembers(pid);
90
+ if (!Array.isArray(members) || members.length > 0) {
91
+ return { state: "unknown", reason: "signal-failed", diagnostic: kill.diagnostic };
92
+ }
93
+ }
94
+ const killExit = await waitUntilGroupTerminal(pid, options.killVerifyMs ?? DEFAULT_KILL_VERIFY_MS);
95
+ if (killExit !== false) {
96
+ return { state: "unknown", reason: "verification-failed", diagnostic: killExit.diagnostic };
97
+ }
98
+ return observed(pid);
99
+ })();
100
+ return termination;
101
+ };
102
+
103
+ return { terminate, finishAfterWriterClose: terminate };
104
+ }
@@ -10,6 +10,7 @@ import {
10
10
  type ProcessTerminalV1,
11
11
  } from "../../shared/types.ts";
12
12
  import { canonicalSessionId, inspectSessionLease } from "../shared/session-lease.ts";
13
+ import { releaseActiveRunIndex } from "./active-run-index.ts";
13
14
 
14
15
  export interface ProcessTerminalCandidate {
15
16
  version: 1;
@@ -40,9 +41,19 @@ function validProcessInstance(value: unknown, kind?: "runner" | "pi-writer"): va
40
41
  if (typeof value.closeObservedAt !== "number" || !Number.isFinite(value.closeObservedAt)) return false;
41
42
  if (typeof value.exitCode !== "number" && value.exitCode !== null) return false;
42
43
  if (typeof value.signal !== "string" && value.signal !== null) return false;
43
- return value.kind === "runner"
44
- ? value.attempt === undefined
45
- : typeof value.attempt === "number" && Number.isInteger(value.attempt) && value.attempt >= 0;
44
+ if (value.kind === "runner") return value.attempt === undefined;
45
+ if (typeof value.attempt !== "number" || !Number.isInteger(value.attempt) || value.attempt < 0 || !isRecord(value.processTree)) return false;
46
+ if (value.processTree.state === "observed") {
47
+ return value.processTree.mechanism === "posix-process-group"
48
+ && typeof value.processTree.processGroupId === "number"
49
+ && Number.isInteger(value.processTree.processGroupId)
50
+ && value.processTree.processGroupId > 0
51
+ && typeof value.processTree.verifiedAt === "number"
52
+ && Number.isFinite(value.processTree.verifiedAt);
53
+ }
54
+ return value.processTree.state === "unknown"
55
+ && ["unsupported-platform", "signal-failed", "verification-failed"].includes(String(value.processTree.reason))
56
+ && (value.processTree.diagnostic === undefined || typeof value.processTree.diagnostic === "string");
46
57
  }
47
58
 
48
59
  function validInstance(value: unknown): value is ProcessInstanceExitV1 {
@@ -249,6 +260,8 @@ export function finalizeProcessTerminal(
249
260
  proof = unknownProof(runId, runnerClose.processInstanceId, "canonical-session-release-unverified");
250
261
  } else if (inconsistentWriters || (allWriters.length === 0 && expectedEntries.length === 0)) {
251
262
  proof = unknownProof(runId, runnerClose.processInstanceId, "writer-close-unverified");
263
+ } else if (allWriters.some((writer) => writer.kind === "pi-writer" && writer.processTree.state !== "observed")) {
264
+ proof = unknownProof(runId, runnerClose.processInstanceId, "process-tree-unverified");
252
265
  } else {
253
266
  const runner: ProcessInstanceExitV1 = { kind: "runner", ...runnerClose };
254
267
  const canonicalSession = session && sessionProjection(candidate, session);
@@ -271,6 +284,7 @@ export function finalizeProcessTerminal(
271
284
  try {
272
285
  writeAtomicJson(processTerminalPath(asyncDir), proof);
273
286
  durable = true;
287
+ if (proof.state === "observed") releaseActiveRunIndex(asyncDir);
274
288
  overlayStatus(asyncDir, proof, candidateForOverlay);
275
289
  fs.appendFileSync(path.join(asyncDir, "events.jsonl"), `${JSON.stringify({ type: "subagent.run.process_terminal", lifecycleArtifactVersion: SUBAGENT_LIFECYCLE_ARTIFACT_VERSION, ts: Date.now(), runId, processTerminal: proof })}\n`, "utf-8");
276
290
  } catch {
@@ -21,14 +21,15 @@ import {
21
21
  import { projectNestedRegistryForRoot, sanitizeSummary } from "../shared/nested-events.ts";
22
22
  import { resolveWatchPath } from "../../shared/utils.ts";
23
23
  import { recordWaitCompletion } from "./wait-completions.ts";
24
- import { syncMissionFromAsyncCompletion } from "../../missions/lifecycle.ts";
24
+ import { MISSION_BINDING_FILE, syncMissionFromAsyncCompletion } from "../../missions/lifecycle.ts";
25
25
  import type { CompletionNotifier, CompletionNotification } from "./notify.ts";
26
26
 
27
27
  const WATCHER_RESTART_DELAY_MS = 3000;
28
28
  const POLL_INTERVAL_MS = 3000;
29
+ const HEALTHY_SCAN_INTERVAL_MS = 60_000;
29
30
  const RETRY_DELAY_MS = 100;
30
31
 
31
- type ResultWatcherFs = Pick<typeof fs, "existsSync" | "readFileSync" | "unlinkSync" | "readdirSync" | "mkdirSync" | "realpathSync" | "watch">;
32
+ type ResultWatcherFs = Pick<typeof fs, "existsSync" | "readFileSync" | "unlinkSync" | "readdirSync" | "mkdirSync" | "realpathSync" | "statSync" | "watch">;
32
33
 
33
34
  type ResultWatcherTimers = {
34
35
  setTimeout: typeof setTimeout;
@@ -43,6 +44,10 @@ type ResultWatcherDeps = {
43
44
  notifier?: Pick<CompletionNotifier, "deliver">;
44
45
  /** Receives persisted completions before active-session delivery filtering. */
45
46
  observeCompletion?: (result: CompletionNotification & { runId: string }) => void;
47
+ /** Returns cross-session run ids that the completion observer currently owns. */
48
+ observedCompletionRunIds?: () => Iterable<string>;
49
+ /** Parses a relevant result payload after its lightweight identity check. */
50
+ parseResult?: (raw: string) => ResultFileData;
46
51
  /** External grouped-result transport. Disable when native completion notifications own delivery. */
47
52
  deliverIntercomResults?: boolean;
48
53
  };
@@ -76,6 +81,33 @@ type ResultFileData = CompletionNotification & {
76
81
  parallelHandoff?: ParallelHandoffReference;
77
82
  };
78
83
 
84
+ type ResultFileIdentity = {
85
+ sessionId?: string;
86
+ runId?: string;
87
+ asyncDir?: string;
88
+ };
89
+
90
+ function jsonStringProperty(raw: string, property: string): string | undefined {
91
+ const matches = raw.matchAll(new RegExp(`"${property}"\\s*:\\s*("(?:\\\\.|[^"\\\\])*")`, "g"));
92
+ let encoded: string | undefined;
93
+ for (const match of matches) encoded = match[1];
94
+ if (!encoded) return undefined;
95
+ try {
96
+ const value = JSON.parse(encoded) as unknown;
97
+ return typeof value === "string" && value ? value : undefined;
98
+ } catch {
99
+ return undefined;
100
+ }
101
+ }
102
+
103
+ function resultFileIdentity(raw: string, file: string): ResultFileIdentity {
104
+ return {
105
+ sessionId: jsonStringProperty(raw, "sessionId"),
106
+ runId: file.replace(/\.json$/i, ""),
107
+ asyncDir: jsonStringProperty(raw, "asyncDir"),
108
+ };
109
+ }
110
+
79
111
  function sanitizeNestedResultChildren(value: unknown, resultPath: string, label: string): NestedRunSummary[] | undefined {
80
112
  if (value === undefined) return undefined;
81
113
  if (!Array.isArray(value)) {
@@ -121,9 +153,11 @@ export function createResultWatcher(
121
153
  const fsApi = deps.fs ?? fs;
122
154
  const timers = deps.timers ?? { setTimeout, clearTimeout, setInterval, clearInterval };
123
155
  const notifier = deps.notifier ?? { deliver: async () => true };
156
+ const parseResult = deps.parseResult ?? ((raw: string) => JSON.parse(raw) as ResultFileData);
124
157
  const deliverIntercomResults = deps.deliverIntercomResults !== false;
125
158
  const pendingTriggerTurn = new Map<string, boolean>();
126
159
  const processing = new Set<string>();
160
+ const identityCache = new Map<string, { signature: string; identity: ResultFileIdentity }>();
127
161
  let deliveryActive = true;
128
162
  let deliveryEpoch = 0;
129
163
  let resultScanTimer: ReturnType<typeof setInterval> | null = null;
@@ -143,12 +177,51 @@ export function createResultWatcher(
143
177
  state.resultFileCoalescer.schedule(file, delayMs);
144
178
  };
145
179
 
180
+ const inspectResult = (file: string): ResultFileIdentity | undefined => {
181
+ const resultPath = path.join(resultsDir, file);
182
+ try {
183
+ const stat = fsApi.statSync(resultPath);
184
+ const signature = `${stat.size}:${stat.mtimeMs}`;
185
+ const cached = identityCache.get(file);
186
+ if (cached?.signature === signature) return cached.identity;
187
+ const identity = resultFileIdentity(fsApi.readFileSync(resultPath, "utf-8"), file);
188
+ identityCache.set(file, { signature, identity });
189
+ return identity;
190
+ } catch (error) {
191
+ identityCache.delete(file);
192
+ if (!isNotFound(error)) console.error(`Failed to inspect subagent result file '${resultPath}':`, error);
193
+ return undefined;
194
+ }
195
+ };
196
+
197
+ const observedRunIds = (): ReadonlySet<string> => {
198
+ try {
199
+ return new Set(deps.observedCompletionRunIds?.() ?? []);
200
+ } catch (error) {
201
+ console.error("Failed to inspect observed subagent completion ids:", error);
202
+ return new Set();
203
+ }
204
+ };
205
+
206
+ const shouldProcessResult = (file: string, observed?: ReadonlySet<string>): boolean => {
207
+ const identity = inspectResult(file);
208
+ if (!identity) return false;
209
+ // Missing identity stays on the normal parser path so malformed or legacy
210
+ // files keep their existing diagnostics and compatibility behavior.
211
+ if (!identity.sessionId) return true;
212
+ if (identity.sessionId === state.currentSessionId) return true;
213
+ if (identity.asyncDir && fsApi.existsSync(path.join(identity.asyncDir, MISSION_BINDING_FILE))) return true;
214
+ if (identity.runId && (observed ?? observedRunIds()).has(identity.runId)) return true;
215
+ return Boolean(deps.observeCompletion && !deps.observedCompletionRunIds);
216
+ };
217
+
146
218
  const handleResult = async (file: string, triggerTurn: boolean) => {
147
219
  const resultPath = path.join(resultsDir, file);
148
220
  if (processing.has(file) || !fsApi.existsSync(resultPath)) return;
221
+ if (!shouldProcessResult(file)) return;
149
222
  processing.add(file);
150
223
  try {
151
- const data = JSON.parse(fsApi.readFileSync(resultPath, "utf-8")) as ResultFileData;
224
+ const data = parseResult(fsApi.readFileSync(resultPath, "utf-8"));
152
225
  if (typeof data.sessionId !== "string" || !data.sessionId) return;
153
226
  const runId = data.runId ?? data.id ?? file.replace(/\.json$/i, "");
154
227
  try {
@@ -189,6 +262,7 @@ export function createResultWatcher(
189
262
  if (!ownsSession(data.sessionId, epoch) || !fsApi.existsSync(resultPath)) return;
190
263
  try {
191
264
  fsApi.unlinkSync(resultPath);
265
+ identityCache.delete(file);
192
266
  } catch (error) {
193
267
  if (!isNotFound(error)) {
194
268
  console.error(`Failed to remove delivered subagent result '${resultPath}'; will retry:`, error);
@@ -314,6 +388,7 @@ export function createResultWatcher(
314
388
  if (!ownsSession(data.sessionId, epoch) || !fsApi.existsSync(resultPath)) return;
315
389
  try {
316
390
  fsApi.unlinkSync(resultPath);
391
+ identityCache.delete(file);
317
392
  } catch (error) {
318
393
  if (!isNotFound(error)) {
319
394
  console.error(`Failed to remove delivered subagent result '${resultPath}'; will retry:`, error);
@@ -338,7 +413,9 @@ export function createResultWatcher(
338
413
  const triggerTurn = options.triggerTurn !== false;
339
414
  fsApi.readdirSync(resultsDir)
340
415
  .filter((f) => f.endsWith(".json"))
341
- .forEach((file) => scheduleResult(file, triggerTurn));
416
+ .forEach((file) => {
417
+ if (shouldProcessResult(file)) scheduleResult(file, triggerTurn);
418
+ });
342
419
  } catch (error) {
343
420
  if (!isNotFound(error)) console.error(`Failed to scan subagent result directory '${resultsDir}':`, error);
344
421
  }
@@ -395,7 +472,10 @@ export function createResultWatcher(
395
472
  return;
396
473
  }
397
474
  const fileName = file.toString();
398
- if (fileName.endsWith(".json")) scheduleResult(fileName, true);
475
+ if (fileName.endsWith(".json")) {
476
+ identityCache.delete(fileName);
477
+ scheduleResult(fileName, true);
478
+ }
399
479
  });
400
480
  state.watcher.on("error", (error) => {
401
481
  if (shouldPoll(error)) return startPolling(error);
@@ -405,7 +485,7 @@ export function createResultWatcher(
405
485
  scheduleRestart();
406
486
  });
407
487
  state.watcher.unref?.();
408
- resultScanTimer = timers.setInterval(primeExistingResults, POLL_INTERVAL_MS);
488
+ resultScanTimer = timers.setInterval(primeExistingResults, HEALTHY_SCAN_INTERVAL_MS);
409
489
  resultScanTimer.unref?.();
410
490
  } catch (error) {
411
491
  if (shouldPoll(error)) return startPolling(error);
@@ -430,6 +510,7 @@ export function createResultWatcher(
430
510
  state.resultFileCoalescer.clear();
431
511
  pendingTriggerTurn.clear();
432
512
  processing.clear();
513
+ identityCache.clear();
433
514
  };
434
515
 
435
516
  return { startResultWatcher, primeExistingResults, stopResultWatcher };
@@ -7,6 +7,7 @@ import { formatNestedRunStatusLines } from "../shared/nested-render.ts";
7
7
  import { formatModelThinking } from "../../shared/formatters.ts";
8
8
  import { formatActivityLabel } from "../../shared/status-format.ts";
9
9
  import { DIRS, type AsyncStatus, type Details, type ForegroundResumeRun, type NestedRunSummary, type SteeringStatus, type SubagentState } from "../../shared/types.ts";
10
+ import { readStatus } from "../../shared/utils.ts";
10
11
  import { resolveSubagentIntercomTarget } from "../../intercom/intercom-bridge.ts";
11
12
  import { resolveSubagentResultStatus } from "../../intercom/result-intercom.ts";
12
13
  import { readProcessTerminal, sanitizeProcessTerminal } from "./process-terminal.ts";
@@ -18,6 +19,7 @@ import { reconcileAsyncRun, reconcileNestedAsyncDescendants } from "./stale-run-
18
19
  import { attachRootChildrenToSteps, findNestedRouteForRootId, projectNestedRegistryForRoot, type NestedRunResolutionScope } from "../shared/nested-events.ts";
19
20
  import { readMissionBinding } from "../../missions/lifecycle.ts";
20
21
  import { formatWorkflowJsonPreview } from "../../workflows/scripted-workflow.ts";
22
+ import { formatRunFanoutBudget, getRunFanoutBudgetSnapshot, readRunFanoutBudgetDescriptor } from "../shared/run-fanout-budget.ts";
21
23
 
22
24
  interface RunStatusParams {
23
25
  action?: string;
@@ -315,6 +317,7 @@ export function inspectSubagentStatus(params: RunStatusParams, deps: RunStatusDe
315
317
  }
316
318
 
317
319
  if (asyncDir) {
320
+ const diskStatus = readStatus(asyncDir);
318
321
  let reconciliation;
319
322
  try {
320
323
  reconciliation = reconcileAsyncRun(asyncDir, { resultsDir, kill: deps.kill, now: deps.now });
@@ -327,6 +330,22 @@ export function inspectSubagentStatus(params: RunStatusParams, deps: RunStatusDe
327
330
  };
328
331
  }
329
332
  const status = reconciliation.status;
333
+ if (!status && diskStatus?.displayDismissedAt !== undefined) {
334
+ if (params.view === "transcript") {
335
+ if (currentSessionId && diskStatus.sessionId !== currentSessionId) {
336
+ return {
337
+ content: [{ type: "text", text: "Transcript view is only available for async runs owned by the current session." }],
338
+ isError: true,
339
+ details: { mode: "single", results: [] },
340
+ };
341
+ }
342
+ return { content: [{ type: "text", text: formatAsyncRunTranscript(diskStatus, asyncDir, { index: params.index, lines: params.lines, sessionRoots: deps.sessionRoots }) }], details: { mode: "single", results: [] } };
343
+ }
344
+ return {
345
+ content: [{ type: "text", text: `Run: ${diskStatus.runId}\nState: display-dismissed\nDismissed: ${new Date(diskStatus.displayDismissedAt).toISOString()}\nNo running work was terminated.` }],
346
+ details: { mode: "single", results: [] },
347
+ };
348
+ }
330
349
  const effectiveRunId = status?.runId ?? resolvedId ?? "unknown";
331
350
  const logPath = path.join(asyncDir, `subagent-log-${effectiveRunId}.md`);
332
351
  const eventsPath = path.join(asyncDir, "events.jsonl");
@@ -371,6 +390,8 @@ export function inspectSubagentStatus(params: RunStatusParams, deps: RunStatusDe
371
390
  const steeringText = formatSteeringSummary(status);
372
391
  const processTerminal = readProcessTerminal(asyncDir, { runId: status.runId, runnerProcessInstanceId: status.processTerminal?.runnerProcessInstanceId })
373
392
  ?? sanitizeProcessTerminal(status.processTerminal, { runId: status.runId, runnerProcessInstanceId: status.processTerminal?.runnerProcessInstanceId }, path.join(asyncDir, "status.json"));
393
+ const runFanoutBudgetDescriptor = readRunFanoutBudgetDescriptor(asyncDir);
394
+ const runFanoutBudget = runFanoutBudgetDescriptor ? getRunFanoutBudgetSnapshot(runFanoutBudgetDescriptor) : status.runFanoutBudget;
374
395
  let missionId: string | undefined;
375
396
  try {
376
397
  missionId = readMissionBinding(asyncDir)?.missionId;
@@ -392,6 +413,7 @@ export function inspectSubagentStatus(params: RunStatusParams, deps: RunStatusDe
392
413
  statusActivityText ? `Activity: ${statusActivityText}` : undefined,
393
414
  steeringText ? `Steering: ${steeringText}` : undefined,
394
415
  `Mode: ${status.mode}`,
416
+ runFanoutBudget ? formatRunFanoutBudget(runFanoutBudget) : undefined,
395
417
  status.parentWorkflowRunId ? `Workflow parent: ${status.parentWorkflowRunId}${status.workflowKey ? ` (${status.workflowKey})` : ""}` : undefined,
396
418
  status.mode === "workflow" && workflowReturnPreview !== undefined ? `Return: ${workflowReturnPreview}` : undefined,
397
419
  status.mode === "workflow" && workflowEmitPreview !== undefined ? `Latest emit: ${workflowEmitPreview}` : undefined,
@@ -448,7 +470,7 @@ export function inspectSubagentStatus(params: RunStatusParams, deps: RunStatusDe
448
470
  if (fs.existsSync(logPath)) lines.push(`Log: ${logPath}`);
449
471
  if (fs.existsSync(eventsPath)) lines.push(`Events: ${eventsPath}`);
450
472
 
451
- return { content: [{ type: "text", text: lines.join("\n") }], details: { mode: "single", results: [], ...(processTerminal ? { lifecycleStatus: { processTerminal } } : {}) } };
473
+ return { content: [{ type: "text", text: lines.join("\n") }], details: { mode: "single", results: [], ...(runFanoutBudget ? { runFanoutBudget } : {}), ...(processTerminal ? { lifecycleStatus: { processTerminal } } : {}) } };
452
474
  }
453
475
  }
454
476
 
@@ -406,6 +406,29 @@ export class ScheduledRunManager {
406
406
  }
407
407
  }
408
408
 
409
+ observedCompletionRunIds(): Set<string> {
410
+ const runIds = new Set<string>();
411
+ for (const store of this.stores.values()) {
412
+ let ids: string[];
413
+ try {
414
+ ids = store.ids();
415
+ } catch (error) {
416
+ console.error(`Failed to inspect schedule store '${store.root}' during async completion discovery:`, error);
417
+ continue;
418
+ }
419
+ for (const id of ids) {
420
+ try {
421
+ for (const run of store.history(id)) {
422
+ if (run.state === "running" && run.asyncId) runIds.add(run.asyncId);
423
+ }
424
+ } catch (error) {
425
+ console.error(`Failed to inspect schedule '${id}' in '${store.root}' during async completion discovery:`, error);
426
+ }
427
+ }
428
+ }
429
+ return runIds;
430
+ }
431
+
409
432
  handleAsyncCompletion(payload: unknown): void {
410
433
  if (!payload || typeof payload !== "object") return;
411
434
  const data = payload as { id?: unknown; runId?: unknown; success?: unknown; state?: unknown; summary?: unknown };