pi-subagents 0.46.0 → 0.47.1

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 (58) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/agents/reviewer.md +3 -4
  3. package/docs/agents.md +1 -1
  4. package/docs/configuration.md +14 -6
  5. package/docs/extension-api.md +1 -1
  6. package/docs/missions.md +5 -3
  7. package/docs/models.md +3 -1
  8. package/docs/observability.md +3 -3
  9. package/docs/tool-reference.md +2 -2
  10. package/docs/watchdog.md +1 -1
  11. package/package.json +1 -1
  12. package/skills/pi-subagents/references/execution-controls.md +4 -4
  13. package/skills/pi-subagents/references/management-authoring-rpc.md +1 -1
  14. package/src/extension/config.ts +13 -0
  15. package/src/extension/fanout-child.ts +5 -4
  16. package/src/extension/index.ts +39 -14
  17. package/src/extension/rpc.ts +7 -6
  18. package/src/extension/schemas.ts +27 -7
  19. package/src/extension/tool-description.ts +26 -8
  20. package/src/inspectors/herdr/project-panes.ts +2 -1
  21. package/src/intercom/native-supervisor-channel.ts +102 -4
  22. package/src/missions/store.ts +2 -1
  23. package/src/missions/workflow-state.ts +19 -13
  24. package/src/runs/background/active-run-index.ts +42 -0
  25. package/src/runs/background/async-execution.ts +10 -5
  26. package/src/runs/background/async-job-tracker.ts +293 -133
  27. package/src/runs/background/async-resume.ts +19 -3
  28. package/src/runs/background/async-status.ts +33 -5
  29. package/src/runs/background/control-channel.ts +91 -17
  30. package/src/runs/background/result-watcher.ts +102 -7
  31. package/src/runs/background/run-status.ts +18 -0
  32. package/src/runs/background/scheduled-runs.ts +25 -1
  33. package/src/runs/background/stale-run-reconciler.ts +12 -24
  34. package/src/runs/background/subagent-runner.ts +68 -10
  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 +3 -0
  38. package/src/runs/foreground/execution.ts +3 -0
  39. package/src/runs/foreground/subagent-executor.ts +129 -20
  40. package/src/runs/foreground/workflow-foreground-steering.ts +187 -0
  41. package/src/runs/shared/dynamic-fanout.ts +1 -1
  42. package/src/runs/shared/model-fallback.ts +8 -4
  43. package/src/runs/shared/model-scope.ts +12 -2
  44. package/src/runs/shared/parallel-utils.ts +1 -0
  45. package/src/runs/shared/subagent-prompt-runtime.ts +24 -5
  46. package/src/runs/shared/worktree.ts +3 -2
  47. package/src/shared/artifacts.ts +15 -15
  48. package/src/shared/display-text.ts +100 -0
  49. package/src/shared/file-coalescer.ts +9 -0
  50. package/src/shared/formatters.ts +4 -6
  51. package/src/shared/settings.ts +15 -2
  52. package/src/shared/types.ts +16 -2
  53. package/src/shared/utils.ts +43 -33
  54. package/src/slash/slash-commands.ts +3 -1
  55. package/src/tui/fleet-status.ts +14 -10
  56. package/src/tui/render.ts +30 -26
  57. package/src/watchdog/change-signature.ts +4 -3
  58. package/src/workflows/scripted-workflow.ts +4 -1
@@ -21,7 +21,18 @@ import { POLL_INTERVAL_MS } from "../../shared/types.ts";
21
21
  import { resolveWatchPath } from "../../shared/utils.ts";
22
22
 
23
23
  export type ControlChannelFs = Pick<typeof fs, "mkdirSync" | "existsSync" | "rmSync" | "watch" | "readdirSync" | "readFileSync" | "realpathSync">;
24
+
25
+ function writeJsonToExistingDir(filePath: string, payload: object): void {
26
+ const tempPath = path.join(path.dirname(filePath), `.${path.basename(filePath)}.${process.pid}.${Date.now()}.${randomUUID()}.tmp`);
27
+ try {
28
+ fs.writeFileSync(tempPath, JSON.stringify(payload, null, 2), { encoding: "utf-8", flag: "wx" });
29
+ fs.renameSync(tempPath, filePath);
30
+ } finally {
31
+ fs.rmSync(tempPath, { force: true });
32
+ }
33
+ }
24
34
  export type ControlChannelTimers = { setInterval: typeof setInterval; clearInterval: typeof clearInterval };
35
+ const CONTROL_SAFETY_POLL_INTERVAL_MS = 5000;
25
36
  type KillFn = (pid: number, signal?: NodeJS.Signals | 0) => unknown;
26
37
 
27
38
  export interface InterruptRequest {
@@ -205,6 +216,13 @@ export function writeSteerRequestToDir(dir: string, request: SteerRequest): stri
205
216
  return requestPath;
206
217
  }
207
218
 
219
+ export function writeSteerRequestToExistingDir(dir: string, request: SteerRequest): string {
220
+ if (!validSteerRequest(request)) throw new Error("steer request is malformed or exceeds transport limits.");
221
+ const requestPath = path.join(dir, steerRequestFileName(request));
222
+ writeJsonToExistingDir(requestPath, request);
223
+ return requestPath;
224
+ }
225
+
208
226
  export function writeSteerCapabilityAt(filePath: string, capability: Omit<SteerCapability, "type" | "protocolVersion">): string {
209
227
  assertChildIndex(capability.index);
210
228
  if (!Number.isInteger(capability.pid) || capability.pid <= 0) throw new Error("steer capability pid must be a positive integer.");
@@ -385,6 +403,25 @@ export function consumeSteerCapabilities(asyncDir: string, fsImpl: Pick<typeof f
385
403
  return capabilities;
386
404
  }
387
405
 
406
+ export function consumeSteerAckFromDir(
407
+ dir: string,
408
+ requestId: string,
409
+ fsImpl: Pick<typeof fs, "existsSync" | "readdirSync" | "readFileSync" | "rmSync"> = fs,
410
+ ): SteerAck | undefined {
411
+ if (!fsImpl.existsSync(dir)) return undefined;
412
+ let entries: string[];
413
+ try { entries = fsImpl.readdirSync(dir).filter((name) => name.endsWith(".json")).sort(); } catch { return undefined; }
414
+ for (const entry of entries) {
415
+ const target = path.join(dir, entry);
416
+ let ack: SteerAck | undefined;
417
+ try { ack = parseSteerAck(JSON.parse(fsImpl.readFileSync(target, "utf-8"))); } catch { ack = undefined; }
418
+ if (ack?.requestId !== requestId) continue;
419
+ try { fsImpl.rmSync(target, { force: true }); } catch { return undefined; }
420
+ return ack;
421
+ }
422
+ return undefined;
423
+ }
424
+
388
425
  export function consumeSteerAcks(asyncDir: string, fsImpl: Pick<typeof fs, "existsSync" | "readdirSync" | "readFileSync" | "rmSync"> = fs): SteerAck[] {
389
426
  const root = path.join(controlInboxDir(asyncDir), STEER_ACKS_DIR);
390
427
  if (!fsImpl.existsSync(root)) return [];
@@ -585,9 +622,9 @@ export function deliverCheckpointDecisionRequest(input: {
585
622
 
586
623
  /**
587
624
  * Runner side: watch the control inbox and route interrupt requests into
588
- * `onInterrupt`. Uses `fs.watch` when available plus an interval poll as a
589
- * portable safety net (covers filesystems/platforms where `fs.watch` is
590
- * 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.
591
628
  */
592
629
  export function watchAsyncControlInbox(
593
630
  asyncDir: string,
@@ -600,6 +637,7 @@ export function watchAsyncControlInbox(
600
637
  onSteerCapability?: (capability: SteerCapability) => void;
601
638
  onSteerAck?: (ack: SteerAck) => void;
602
639
  pollIntervalMs?: number;
640
+ safetyPollIntervalMs?: number;
603
641
  fs?: ControlChannelFs;
604
642
  timers?: ControlChannelTimers;
605
643
  },
@@ -633,27 +671,63 @@ export function watchAsyncControlInbox(
633
671
  // Handle a request that may have arrived before the watcher started.
634
672
  check();
635
673
 
636
- let watcher: fs.FSWatcher | undefined;
637
- try {
638
- watcher = fsImpl.watch(resolveWatchPath(dir, fsImpl.realpathSync.native), () => check());
639
- watcher.on?.("error", () => {
640
- // 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();
641
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();
642
716
  } catch {
643
- watcher = undefined;
717
+ startPolling();
644
718
  }
645
719
 
646
- const interval = timers.setInterval(check, opts.pollIntervalMs ?? POLL_INTERVAL_MS);
647
- interval.unref?.();
648
-
649
720
  return () => {
650
721
  if (disposed) return;
651
722
  disposed = true;
652
- try {
653
- watcher?.close();
654
- } catch {
655
- // ignore
723
+ for (const watcher of watchers) {
724
+ try {
725
+ watcher.close();
726
+ } catch {
727
+ // ignore
728
+ }
656
729
  }
657
- timers.clearInterval(interval);
730
+ if (interval) timers.clearInterval(interval);
731
+ if (safetyInterval) timers.clearInterval(safetyInterval);
658
732
  };
659
733
  }
@@ -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,11 +153,14 @@ 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;
163
+ let resultScanTimer: ReturnType<typeof setInterval> | null = null;
129
164
  // The sole in-memory ownership lease. It is acquired for one active session
130
165
  // and revoked before the watcher, queues, or callbacks are torn down.
131
166
  let activeSessionId: string | null = null;
@@ -142,12 +177,51 @@ export function createResultWatcher(
142
177
  state.resultFileCoalescer.schedule(file, delayMs);
143
178
  };
144
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
+
145
218
  const handleResult = async (file: string, triggerTurn: boolean) => {
146
219
  const resultPath = path.join(resultsDir, file);
147
220
  if (processing.has(file) || !fsApi.existsSync(resultPath)) return;
221
+ if (!shouldProcessResult(file)) return;
148
222
  processing.add(file);
149
223
  try {
150
- const data = JSON.parse(fsApi.readFileSync(resultPath, "utf-8")) as ResultFileData;
224
+ const data = parseResult(fsApi.readFileSync(resultPath, "utf-8"));
151
225
  if (typeof data.sessionId !== "string" || !data.sessionId) return;
152
226
  const runId = data.runId ?? data.id ?? file.replace(/\.json$/i, "");
153
227
  try {
@@ -188,6 +262,7 @@ export function createResultWatcher(
188
262
  if (!ownsSession(data.sessionId, epoch) || !fsApi.existsSync(resultPath)) return;
189
263
  try {
190
264
  fsApi.unlinkSync(resultPath);
265
+ identityCache.delete(file);
191
266
  } catch (error) {
192
267
  if (!isNotFound(error)) {
193
268
  console.error(`Failed to remove delivered subagent result '${resultPath}'; will retry:`, error);
@@ -313,6 +388,7 @@ export function createResultWatcher(
313
388
  if (!ownsSession(data.sessionId, epoch) || !fsApi.existsSync(resultPath)) return;
314
389
  try {
315
390
  fsApi.unlinkSync(resultPath);
391
+ identityCache.delete(file);
316
392
  } catch (error) {
317
393
  if (!isNotFound(error)) {
318
394
  console.error(`Failed to remove delivered subagent result '${resultPath}'; will retry:`, error);
@@ -337,15 +413,23 @@ export function createResultWatcher(
337
413
  const triggerTurn = options.triggerTurn !== false;
338
414
  fsApi.readdirSync(resultsDir)
339
415
  .filter((f) => f.endsWith(".json"))
340
- .forEach((file) => scheduleResult(file, triggerTurn));
416
+ .forEach((file) => {
417
+ if (shouldProcessResult(file)) scheduleResult(file, triggerTurn);
418
+ });
341
419
  } catch (error) {
342
420
  if (!isNotFound(error)) console.error(`Failed to scan subagent result directory '${resultsDir}':`, error);
343
421
  }
344
422
  };
345
423
 
424
+ const clearResultScan = () => {
425
+ if (resultScanTimer) timers.clearInterval(resultScanTimer);
426
+ resultScanTimer = null;
427
+ };
428
+
346
429
  const startPolling = (reason: unknown) => {
347
430
  state.watcher?.close();
348
431
  state.watcher = null;
432
+ clearResultScan();
349
433
  if (state.watcherRestartTimer) return;
350
434
  console.error(`Subagent result watcher for '${resultsDir}' fell back to polling because native fs.watch is unavailable (${errorCode(reason) ?? "unknown error"}).`);
351
435
  primeExistingResults();
@@ -354,6 +438,7 @@ export function createResultWatcher(
354
438
  };
355
439
 
356
440
  const scheduleRestart = () => {
441
+ clearResultScan();
357
442
  if (state.watcherRestartTimer) return;
358
443
  state.watcherRestartTimer = timers.setTimeout(() => {
359
444
  state.watcherRestartTimer = null;
@@ -381,10 +466,16 @@ export function createResultWatcher(
381
466
  }
382
467
  try {
383
468
  const watchDir = resolveWatchPath(resultsDir, fsApi.realpathSync.native);
384
- state.watcher = fsApi.watch(watchDir, (event, file) => {
385
- if (event !== "rename" || !file) return;
469
+ state.watcher = fsApi.watch(watchDir, (_event, file) => {
470
+ if (!file) {
471
+ primeExistingResults();
472
+ return;
473
+ }
386
474
  const fileName = file.toString();
387
- if (fileName.endsWith(".json")) scheduleResult(fileName, true);
475
+ if (fileName.endsWith(".json")) {
476
+ identityCache.delete(fileName);
477
+ scheduleResult(fileName, true);
478
+ }
388
479
  });
389
480
  state.watcher.on("error", (error) => {
390
481
  if (shouldPoll(error)) return startPolling(error);
@@ -394,6 +485,8 @@ export function createResultWatcher(
394
485
  scheduleRestart();
395
486
  });
396
487
  state.watcher.unref?.();
488
+ resultScanTimer = timers.setInterval(primeExistingResults, HEALTHY_SCAN_INTERVAL_MS);
489
+ resultScanTimer.unref?.();
397
490
  } catch (error) {
398
491
  if (shouldPoll(error)) return startPolling(error);
399
492
  console.error(`Failed to start subagent result watcher for '${resultsDir}':`, error);
@@ -413,9 +506,11 @@ export function createResultWatcher(
413
506
  timers.clearInterval(state.watcherRestartTimer);
414
507
  }
415
508
  state.watcherRestartTimer = null;
509
+ clearResultScan();
416
510
  state.resultFileCoalescer.clear();
417
511
  pendingTriggerTurn.clear();
418
512
  processing.clear();
513
+ identityCache.clear();
419
514
  };
420
515
 
421
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";
@@ -315,6 +316,7 @@ export function inspectSubagentStatus(params: RunStatusParams, deps: RunStatusDe
315
316
  }
316
317
 
317
318
  if (asyncDir) {
319
+ const diskStatus = readStatus(asyncDir);
318
320
  let reconciliation;
319
321
  try {
320
322
  reconciliation = reconcileAsyncRun(asyncDir, { resultsDir, kill: deps.kill, now: deps.now });
@@ -327,6 +329,22 @@ export function inspectSubagentStatus(params: RunStatusParams, deps: RunStatusDe
327
329
  };
328
330
  }
329
331
  const status = reconciliation.status;
332
+ if (!status && diskStatus?.displayDismissedAt !== undefined) {
333
+ if (params.view === "transcript") {
334
+ if (currentSessionId && diskStatus.sessionId !== currentSessionId) {
335
+ return {
336
+ content: [{ type: "text", text: "Transcript view is only available for async runs owned by the current session." }],
337
+ isError: true,
338
+ details: { mode: "single", results: [] },
339
+ };
340
+ }
341
+ return { content: [{ type: "text", text: formatAsyncRunTranscript(diskStatus, asyncDir, { index: params.index, lines: params.lines, sessionRoots: deps.sessionRoots }) }], details: { mode: "single", results: [] } };
342
+ }
343
+ return {
344
+ content: [{ type: "text", text: `Run: ${diskStatus.runId}\nState: display-dismissed\nDismissed: ${new Date(diskStatus.displayDismissedAt).toISOString()}\nNo running work was terminated.` }],
345
+ details: { mode: "single", results: [] },
346
+ };
347
+ }
330
348
  const effectiveRunId = status?.runId ?? resolvedId ?? "unknown";
331
349
  const logPath = path.join(asyncDir, `subagent-log-${effectiveRunId}.md`);
332
350
  const eventsPath = path.join(asyncDir, "events.jsonl");
@@ -3,6 +3,7 @@ import * as fs from "node:fs";
3
3
  import * as path from "node:path";
4
4
  import type { AgentToolResult } from "@earendil-works/pi-agent-core";
5
5
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
6
+ import { getProjectSubagentsDir } from "../../shared/artifacts.ts";
6
7
  import { writePrivateAtomicJson } from "../../shared/atomic-json.ts";
7
8
  import { shortenPath } from "../../shared/formatters.ts";
8
9
  import type { AsyncStatus, Details, ExtensionConfig } from "../../shared/types.ts";
@@ -87,7 +88,7 @@ export function scheduledRunsEnabled(config: ExtensionConfig): boolean {
87
88
  }
88
89
 
89
90
  export function scheduledRunStorePath(cwd: string, _sessionId?: string, root?: string): string {
90
- if (!root) return path.join(path.resolve(cwd), ".pi-subagents", "schedules");
91
+ if (!root) return path.join(getProjectSubagentsDir(path.resolve(cwd)), "schedules");
91
92
  const projectKey = createHash("sha256").update(path.resolve(cwd)).digest("hex").slice(0, 20);
92
93
  return path.join(root, projectKey);
93
94
  }
@@ -405,6 +406,29 @@ export class ScheduledRunManager {
405
406
  }
406
407
  }
407
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
+
408
432
  handleAsyncCompletion(payload: unknown): void {
409
433
  if (!payload || typeof payload !== "object") return;
410
434
  const data = payload as { id?: unknown; runId?: unknown; success?: unknown; state?: unknown; summary?: unknown };
@@ -1,6 +1,8 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
3
  import { writeAtomicJson } from "../../shared/atomic-json.ts";
4
+ import { updateActiveRunIndex } from "./active-run-index.ts";
5
+ import { readStatus } from "../../shared/utils.ts";
4
6
  import { DIRS, type AsyncParallelGroupStatus, type AsyncStatus, type NestedRunSummary, type SubagentRunMode } from "../../shared/types.ts";
5
7
  import { resolveEffectiveThinking } from "../../shared/model-info.ts";
6
8
  import { normalizeParallelGroups } from "./parallel-groups.ts";
@@ -84,26 +86,6 @@ function appendJsonlBestEffort(filePath: string, payload: object): void {
84
86
  }
85
87
  }
86
88
 
87
- function readStatusFile(asyncDir: string): AsyncStatus | null {
88
- const statusPath = path.join(asyncDir, "status.json");
89
- let content: string;
90
- try {
91
- content = fs.readFileSync(statusPath, "utf-8");
92
- } catch (error) {
93
- if (isNotFoundError(error)) return null;
94
- throw new Error(`Failed to read async status file '${statusPath}': ${getErrorMessage(error)}`, {
95
- cause: error instanceof Error ? error : undefined,
96
- });
97
- }
98
- try {
99
- return JSON.parse(content) as AsyncStatus;
100
- } catch (error) {
101
- throw new Error(`Failed to parse async status file '${statusPath}': ${getErrorMessage(error)}`, {
102
- cause: error instanceof Error ? error : undefined,
103
- });
104
- }
105
- }
106
-
107
89
  interface ResultChildOutcome {
108
90
  agent?: string;
109
91
  success?: boolean;
@@ -172,7 +154,7 @@ function terminalStatusFromResult(status: AsyncStatus, resultPath: string, now:
172
154
  modelAttempts: child?.modelAttempts ?? step.modelAttempts,
173
155
  };
174
156
  });
175
- return {
157
+ const terminalStatus: AsyncStatus = {
176
158
  ...status,
177
159
  state: repair.state,
178
160
  ...(status.lifecycleArtifactVersion === 3 && (!status.processTerminal || status.processTerminal.state === "pending") ? {
@@ -184,6 +166,8 @@ function terminalStatusFromResult(status: AsyncStatus, resultPath: string, now:
184
166
  endedAt: status.endedAt ?? now,
185
167
  steps,
186
168
  };
169
+ delete terminalStatus.displayDismissedAt;
170
+ return terminalStatus;
187
171
  }
188
172
 
189
173
  function buildStartedStatus(asyncDir: string, startedRun: StartedRunMetadata, now: number): AsyncStatus {
@@ -277,6 +261,7 @@ function writeFailedRepair(asyncDir: string, status: AsyncStatus, resultPath: st
277
261
  const repair = buildFailedRepair(status, asyncDir, now, reason);
278
262
  writeAtomicJson(resultPath, repair.result);
279
263
  writeAtomicJson(path.join(asyncDir, "status.json"), repair.status);
264
+ updateActiveRunIndex(asyncDir, repair.status.state);
280
265
  appendJsonlBestEffort(path.join(asyncDir, "events.jsonl"), {
281
266
  type: "subagent.run.repaired_stale",
282
267
  ts: now,
@@ -348,7 +333,7 @@ export function checkPidLiveness(pid: number, kill: KillFn = process.kill): PidL
348
333
 
349
334
  export function reconcileAsyncRun(asyncDir: string, options: ReconcileAsyncRunOptions = {}): ReconcileAsyncRunResult {
350
335
  const now = options.now?.() ?? Date.now();
351
- const status = readStatusFile(asyncDir);
336
+ const status = readStatus(asyncDir);
352
337
  const startedStatus = !status && options.startedRun ? buildStartedStatus(asyncDir, options.startedRun, now) : undefined;
353
338
  const effectiveStatus = status ?? startedStatus;
354
339
  if (!effectiveStatus) return { status: null, repaired: false };
@@ -358,7 +343,6 @@ export function reconcileAsyncRun(asyncDir: string, options: ReconcileAsyncRunOp
358
343
  if (stepRecord.model !== undefined && typeof stepRecord.model !== "string") throw new Error(`Invalid async status file '${statusPath}': steps[${index}].model must be a string.`);
359
344
  if (stepRecord.thinking !== undefined && typeof stepRecord.thinking !== "string") throw new Error(`Invalid async status file '${statusPath}': steps[${index}].thinking must be a string.`);
360
345
  }
361
-
362
346
  const runId = effectiveStatus.runId || path.basename(asyncDir);
363
347
  const resultPath = path.join(options.resultsDir ?? DIRS.results, `${runId}.json`);
364
348
  if (fs.existsSync(resultPath)) {
@@ -367,9 +351,13 @@ export function reconcileAsyncRun(asyncDir: string, options: ReconcileAsyncRunOp
367
351
  : undefined;
368
352
  if (terminalStatus) {
369
353
  writeAtomicJson(path.join(asyncDir, "status.json"), terminalStatus);
354
+ updateActiveRunIndex(asyncDir, terminalStatus.state);
370
355
  return { status: terminalStatus, repaired: true, resultPath, message: "Existing async result file was used to repair stale running status." };
371
356
  }
372
- return { status: effectiveStatus, repaired: false, resultPath };
357
+ if (effectiveStatus.displayDismissedAt === undefined) return { status: effectiveStatus, repaired: false, resultPath };
358
+ }
359
+ if (effectiveStatus.displayDismissedAt !== undefined) {
360
+ return { status: null, repaired: false, resultPath };
373
361
  }
374
362
 
375
363
  if (effectiveStatus.state !== "running" || typeof effectiveStatus.pid !== "number") {