pi-subagents 0.50.0 → 0.52.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 (109) hide show
  1. package/CHANGELOG.md +103 -0
  2. package/agents/oracle.md +3 -1
  3. package/agents/reviewer.md +1 -0
  4. package/agents/scout.md +2 -2
  5. package/agents/worker.md +2 -1
  6. package/async-retention-discovery-worker.mjs +180 -0
  7. package/docs/agents.md +40 -2
  8. package/docs/configuration.md +30 -12
  9. package/docs/extension-api.md +42 -1
  10. package/docs/models.md +2 -0
  11. package/docs/observability.md +41 -5
  12. package/docs/tool-reference.md +38 -39
  13. package/docs/workflows.md +171 -3
  14. package/package.json +4 -2
  15. package/skills/pi-subagents/SKILL.md +6 -4
  16. package/skills/pi-subagents/references/constraints-and-recipes.md +12 -6
  17. package/skills/pi-subagents/references/execution-controls.md +29 -15
  18. package/skills/pi-subagents/references/management-authoring-rpc.md +3 -3
  19. package/skills/pi-subagents/references/prompting-and-roles.md +4 -2
  20. package/src/agents/agent-management.ts +124 -351
  21. package/src/agents/agents.ts +157 -31
  22. package/src/agents/skills.ts +1 -1
  23. package/src/api/external-job-provider.ts +185 -0
  24. package/src/api/preflight.ts +36 -8
  25. package/src/api/shared-types.ts +2 -0
  26. package/src/extension/config.ts +3 -3
  27. package/src/extension/doctor.ts +3 -6
  28. package/src/extension/fanout-child.ts +2 -2
  29. package/src/extension/index.ts +166 -88
  30. package/src/extension/public-execution.ts +31 -2
  31. package/src/extension/schemas.ts +12 -35
  32. package/src/extension/tool-description.ts +35 -24
  33. package/src/inspectors/herdr/actions.ts +2 -2
  34. package/src/inspectors/herdr/inspector-runner.ts +2 -1
  35. package/src/inspectors/herdr/project-panes.ts +2 -2
  36. package/src/intercom/native-supervisor-channel.ts +32 -10
  37. package/src/missions/lifecycle.ts +6 -1
  38. package/src/missions/store.ts +4 -9
  39. package/src/missions/workflow-state.ts +2 -2
  40. package/src/profiles/profiles.ts +3 -1
  41. package/src/runs/background/active-run-index.ts +31 -8
  42. package/src/runs/background/async-execution.ts +68 -40
  43. package/src/runs/background/async-job-tracker.ts +20 -4
  44. package/src/runs/background/async-resume.ts +47 -15
  45. package/src/runs/background/async-retention.ts +886 -0
  46. package/src/runs/background/async-status.ts +39 -53
  47. package/src/runs/background/chain-append.ts +3 -33
  48. package/src/runs/background/completion-dedupe.ts +5 -1
  49. package/src/runs/background/completion-replay.ts +22 -12
  50. package/src/runs/background/control-channel.ts +14 -68
  51. package/src/runs/background/fleet-view.ts +68 -19
  52. package/src/runs/background/index-segment.ts +59 -0
  53. package/src/runs/background/inspect-rpc.ts +443 -0
  54. package/src/runs/background/notify.ts +31 -5
  55. package/src/runs/background/result-files.ts +158 -90
  56. package/src/runs/background/result-watcher.ts +116 -20
  57. package/src/runs/background/resume-guidance.ts +8 -5
  58. package/src/runs/background/retained-children.ts +13 -3
  59. package/src/runs/background/run-id-query.ts +7 -0
  60. package/src/runs/background/run-id-resolver.ts +11 -9
  61. package/src/runs/background/run-status.ts +27 -18
  62. package/src/runs/background/scheduled-runs.ts +24 -4
  63. package/src/runs/background/stale-run-reconciler.ts +6 -3
  64. package/src/runs/background/steering.ts +11 -1
  65. package/src/runs/background/subagent-runner.ts +253 -140
  66. package/src/runs/background/subagent-wait.ts +8 -8
  67. package/src/runs/background/terminal-run-index.ts +129 -0
  68. package/src/runs/background/wait-completions.ts +21 -4
  69. package/src/runs/background/wait-subscriptions.ts +81 -2
  70. package/src/runs/foreground/async-steering-action.ts +21 -14
  71. package/src/runs/foreground/execution.ts +3 -1
  72. package/src/runs/foreground/subagent-executor.ts +666 -1579
  73. package/src/runs/foreground/workflow-detach-reconcile.ts +194 -0
  74. package/src/runs/foreground/workflow-foreground-steering.ts +6 -5
  75. package/src/runs/shared/acceptance.ts +4 -4
  76. package/src/runs/shared/chain-outputs.ts +1 -3
  77. package/src/runs/shared/external-job-bridge.ts +444 -0
  78. package/src/runs/shared/external-job-runner.ts +286 -0
  79. package/src/runs/shared/mcp-direct-tool-allowlist.ts +14 -0
  80. package/src/runs/shared/model-fallback.ts +98 -38
  81. package/src/runs/shared/orca-progress-tabs.ts +84 -22
  82. package/src/runs/shared/parallel-handoff.ts +46 -4
  83. package/src/runs/shared/parallel-utils.ts +4 -15
  84. package/src/runs/shared/permissions.ts +5 -1
  85. package/src/runs/shared/pi-args.ts +8 -1
  86. package/src/runs/shared/session-lease.ts +0 -6
  87. package/src/runs/shared/subagent-control.ts +26 -4
  88. package/src/runs/shared/subagent-prompt-runtime.ts +12 -6
  89. package/src/runs/shared/workflow-graph.ts +1 -23
  90. package/src/runs/shared/worktree.ts +14 -2
  91. package/src/shared/atomic-json.ts +22 -2
  92. package/src/shared/capacity-resilient-json.ts +102 -0
  93. package/src/shared/completion-owner.ts +14 -0
  94. package/src/shared/file-system-retry.ts +49 -1
  95. package/src/shared/fork-context.ts +42 -0
  96. package/src/shared/prompt-resources.ts +0 -40
  97. package/src/shared/settings.ts +3 -27
  98. package/src/shared/types.ts +71 -26
  99. package/src/shared/utils.ts +8 -0
  100. package/src/shared/watch-strategy.ts +10 -0
  101. package/src/slash/slash-bridge.ts +2 -1
  102. package/src/slash/slash-commands.ts +29 -3
  103. package/src/tui/fleet.ts +63 -15
  104. package/src/watchdog/change-signature.ts +1 -1
  105. package/src/watchdog/lsp-diagnostics.ts +1 -0
  106. package/src/workflows/chat-progress.ts +2 -2
  107. package/src/workflows/scripted-workflow.ts +220 -67
  108. package/src/runs/foreground/chain-clarify.ts +0 -1354
  109. package/src/runs/foreground/chain-execution.ts +0 -1581
@@ -2,6 +2,7 @@ import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
3
  import { buildCompletionKey, markSeenWithTtl } from "./completion-dedupe.ts";
4
4
  import { createFileCoalescer } from "../../shared/file-coalescer.ts";
5
+ import { shouldUseNativeFsWatch } from "../../shared/watch-strategy.ts";
5
6
  import {
6
7
  SUBAGENT_ASYNC_COMPLETE_EVENT,
7
8
  type IntercomEventBus,
@@ -54,6 +55,9 @@ type ResultWatcherDeps = {
54
55
  deliverIntercomResults?: boolean;
55
56
  /** Coalesces result-file events. Tests can lower this without changing retry timing. */
56
57
  coalesceDelayMs?: number;
58
+ /** Returns true while a durable completion source needs periodic delivery checks. */
59
+ hasDeliveryDemand?: () => boolean;
60
+ platform?: NodeJS.Platform;
57
61
  };
58
62
 
59
63
  type ResultFileChild = {
@@ -88,6 +92,7 @@ type ResultFileData = CompletionNotification & {
88
92
 
89
93
  type ResultFileIdentity = {
90
94
  sessionId?: string;
95
+ completionOwnerId?: string;
91
96
  runId?: string;
92
97
  asyncDir?: string;
93
98
  };
@@ -114,6 +119,7 @@ function jsonStringProperty(raw: string, property: string): string | undefined {
114
119
  function resultFileIdentity(raw: string, file: string): ResultFileIdentity {
115
120
  return {
116
121
  sessionId: jsonStringProperty(raw, "sessionId"),
122
+ completionOwnerId: jsonStringProperty(raw, "completionOwnerId"),
117
123
  runId: file.replace(/\.json$/i, ""),
118
124
  asyncDir: jsonStringProperty(raw, "asyncDir"),
119
125
  };
@@ -140,6 +146,11 @@ function isNotFound(error: unknown): boolean {
140
146
  return errorCode(error) === "ENOENT";
141
147
  }
142
148
 
149
+ function isAccessDenied(error: unknown): boolean {
150
+ const code = errorCode(error);
151
+ return code === "EPERM" || code === "EACCES";
152
+ }
153
+
143
154
  function shouldPoll(error: unknown): boolean {
144
155
  const code = errorCode(error);
145
156
  return code === "EMFILE" || code === "ENOSPC";
@@ -149,6 +160,21 @@ function hasDeliveredNotification(data: ResultFileData): boolean {
149
160
  return typeof data.notificationDeliveredAt === "number" && Number.isFinite(data.notificationDeliveredAt);
150
161
  }
151
162
 
163
+ type PublicResultIdentity = {
164
+ state?: string;
165
+ timestamp?: number;
166
+ };
167
+
168
+ function resultPayloadWasReplaced(delivered: ResultFileData, disk: PublicResultIdentity | undefined): boolean {
169
+ if (!disk) return false;
170
+ const deliveredState = typeof delivered.state === "string" ? delivered.state : undefined;
171
+ if (disk.state && deliveredState && disk.state !== deliveredState) return true;
172
+ const deliveredTimestamp = typeof delivered.timestamp === "number" && Number.isFinite(delivered.timestamp)
173
+ ? delivered.timestamp
174
+ : undefined;
175
+ return disk.timestamp !== undefined && deliveredTimestamp !== undefined && disk.timestamp !== deliveredTimestamp;
176
+ }
177
+
152
178
  function markDeliveredNotification(resultPath: string, data: ResultFileData, runId: string, now: number): ResultFileData {
153
179
  const marked = { ...data, runId, notificationDeliveredAt: now };
154
180
  writeAsyncResultFile(resultPath, marked);
@@ -169,6 +195,7 @@ export function createResultWatcher(
169
195
  ): {
170
196
  startResultWatcher: () => void;
171
197
  primeExistingResults: (options?: { triggerTurn?: boolean }) => void;
198
+ refreshResultDelivery: () => void;
172
199
  stopResultWatcher: () => void;
173
200
  } {
174
201
  const fsApi = deps.fs ?? fs;
@@ -186,10 +213,13 @@ export function createResultWatcher(
186
213
  // and revoked before the watcher, queues, or callbacks are torn down.
187
214
  let activeSessionId: string | null = null;
188
215
 
189
- const ownsSession = (sessionId: string, epoch: number) => {
216
+ const ownsCompletion = (sessionId: string, completionOwnerId: unknown, epoch: number) => {
190
217
  if (!deliveryActive || epoch !== deliveryEpoch) return false;
191
218
  if (!activeSessionId && state.currentSessionId) activeSessionId = state.currentSessionId;
192
- return activeSessionId === sessionId && state.currentSessionId === sessionId;
219
+ return activeSessionId === sessionId
220
+ && state.currentSessionId === sessionId
221
+ && typeof completionOwnerId === "string"
222
+ && completionOwnerId === state.completionOwnerId;
193
223
  };
194
224
 
195
225
  const scheduleResult = (file: string, triggerTurn: boolean, delayMs = 0) => {
@@ -232,6 +262,7 @@ export function createResultWatcher(
232
262
  return `${resultPath}:${stat.size}:${stat.mtimeMs}`;
233
263
  } catch (error) {
234
264
  identityCache.delete(file);
265
+ if (isAccessDenied(error)) throw error;
235
266
  if (!isNotFound(error)) console.error(`Failed to inspect subagent result file '${resultPath}':`, error);
236
267
  return undefined;
237
268
  }
@@ -249,6 +280,7 @@ export function createResultWatcher(
249
280
  return { identity, signature };
250
281
  } catch (error) {
251
282
  identityCache.delete(file);
283
+ if (isAccessDenied(error)) throw error;
252
284
  if (!isNotFound(error)) console.error(`Failed to inspect subagent result file '${resultPath}':`, error);
253
285
  return undefined;
254
286
  }
@@ -270,7 +302,7 @@ export function createResultWatcher(
270
302
  // Missing identity stays on the normal parser path so malformed or legacy
271
303
  // files keep their existing diagnostics and compatibility behavior.
272
304
  if (!identity.sessionId) return true;
273
- if (identity.sessionId === state.currentSessionId) return true;
305
+ if (identity.sessionId === state.currentSessionId && identity.completionOwnerId === state.completionOwnerId) return true;
274
306
  if (identity.asyncDir && fsApi.existsSync(path.join(identity.asyncDir, MISSION_BINDING_FILE))) return true;
275
307
  if (identity.runId && (observed ?? observedRunIds()).has(identity.runId)) return true;
276
308
  return Boolean(deps.observeCompletion && !deps.observedCompletionRunIds);
@@ -293,13 +325,33 @@ export function createResultWatcher(
293
325
  const handleResult = async (file: string, triggerTurn: boolean) => {
294
326
  if (processing.has(file)) return;
295
327
  let observed: ReadonlySet<string> | undefined;
296
- if (!shouldProcessResult(file)) {
297
- const runId = file === path.basename(file) && file.endsWith(".json") ? file.replace(/\.json$/i, "") : undefined;
298
- observed = observedRunIds();
299
- if (!runId || !observed.has(runId) || !shouldProcessResult(file, observed)) return;
328
+ try {
329
+ if (!shouldProcessResult(file)) {
330
+ const runId = file === path.basename(file) && file.endsWith(".json") ? file.replace(/\.json$/i, "") : undefined;
331
+ observed = observedRunIds();
332
+ if (!runId || !observed.has(runId) || !shouldProcessResult(file, observed)) return;
333
+ }
334
+ } catch (error) {
335
+ if (!isAccessDenied(error)) throw error;
336
+ console.error(`Failed to inspect subagent result file '${publicResultPath(file)}'; will retry:`, error);
337
+ scheduleResult(file, triggerTurn, RETRY_DELAY_MS);
338
+ return;
300
339
  }
301
340
  processing.add(file);
341
+ let rereadReplacedPayload = false;
302
342
  let resultPath = publicResultPath(file);
343
+ const readPublicResultIdentity = (): PublicResultIdentity | undefined => {
344
+ if (!publicResultFileExists(file)) return undefined;
345
+ const parsed: unknown = JSON.parse(fsApi.readFileSync(publicResultPath(file), "utf-8"));
346
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return undefined;
347
+ const record = parsed as Record<string, unknown>;
348
+ const state = typeof record.state === "string" && record.state ? record.state : undefined;
349
+ const timestamp = typeof record.timestamp === "number" && Number.isFinite(record.timestamp)
350
+ ? record.timestamp
351
+ : undefined;
352
+ if (!state && timestamp === undefined) return undefined;
353
+ return { state, timestamp };
354
+ };
303
355
  try {
304
356
  const payloadPath = resultPayloadPath(file, observed);
305
357
  if (!payloadPath) return;
@@ -325,8 +377,21 @@ export function createResultWatcher(
325
377
  }
326
378
  }
327
379
  let data = parseResult(raw);
380
+ const markReplacedPayload = (): boolean => {
381
+ try {
382
+ if (!resultPayloadWasReplaced(data, readPublicResultIdentity())) return false;
383
+ } catch (error) {
384
+ if (isAccessDenied(error)) throw error;
385
+ if (isNotFound(error)) return false;
386
+ console.error(`Failed to re-read subagent result file '${publicResultPath(file)}':`, error);
387
+ }
388
+ identityCache.delete(file);
389
+ rereadReplacedPayload = true;
390
+ return true;
391
+ };
328
392
  if (typeof data.sessionId !== "string" || !data.sessionId) return;
329
393
  const sessionId = data.sessionId;
394
+ const completionOwnerId = data.completionOwnerId;
330
395
  const runId = data.runId ?? data.id ?? file.replace(/\.json$/i, "");
331
396
  const toolCallId = typeof data.toolCallId === "string" ? data.toolCallId : undefined;
332
397
  let observerSucceeded = true;
@@ -344,7 +409,7 @@ export function createResultWatcher(
344
409
  }
345
410
  if (observerSucceeded) removeMissionObserverIndex(resultsDir, runId);
346
411
  const epoch = deliveryEpoch;
347
- if (!ownsSession(sessionId, epoch)) return;
412
+ if (!ownsCompletion(sessionId, completionOwnerId, epoch)) return;
348
413
  // Recorded before dedupe and before the unlink below so subagent_wait can
349
414
  // use the in-memory record or its bounded durable replay after cleanup.
350
415
  recordWaitCompletion(state, runId, data, Date.now(), completionTtlMs, {
@@ -373,7 +438,8 @@ export function createResultWatcher(
373
438
  scheduleResult(file, triggerTurn, RETRY_DELAY_MS);
374
439
  return;
375
440
  }
376
- if (!ownsSession(sessionId, epoch)) return;
441
+ if (!ownsCompletion(sessionId, completionOwnerId, epoch)) return;
442
+ if (markReplacedPayload()) return;
377
443
  if (!removeDeliveredResult(file, sessionId, runId, toolCallId)) scheduleResult(file, triggerTurn, RETRY_DELAY_MS);
378
444
  return;
379
445
  }
@@ -428,7 +494,8 @@ export function createResultWatcher(
428
494
  scheduleResult(file, triggerTurn, RETRY_DELAY_MS);
429
495
  return;
430
496
  }
431
- if (!ownsSession(sessionId, epoch)) return;
497
+ if (!ownsCompletion(sessionId, completionOwnerId, epoch)) return;
498
+ if (markReplacedPayload()) return;
432
499
  if (!removeDeliveredResult(file, sessionId, runId, toolCallId)) scheduleResult(file, triggerTurn, RETRY_DELAY_MS);
433
500
  return;
434
501
  }
@@ -449,7 +516,7 @@ export function createResultWatcher(
449
516
  asyncDir: data.asyncDir,
450
517
  ...(data.parallelHandoff ? { parallelHandoff: data.parallelHandoff } : {}),
451
518
  }));
452
- if (!ownsSession(sessionId, epoch)) return;
519
+ if (!ownsCompletion(sessionId, completionOwnerId, epoch)) return;
453
520
  if (!intercomDelivered) console.error(`Subagent async grouped result intercom delivery was not acknowledged for '${resultPath}'.`);
454
521
  }
455
522
 
@@ -473,11 +540,12 @@ export function createResultWatcher(
473
540
  })) : [],
474
541
  } : {}),
475
542
  });
476
- if (!ownsSession(sessionId, epoch)) return;
543
+ if (!ownsCompletion(sessionId, completionOwnerId, epoch)) return;
477
544
  if (!accepted) {
478
545
  scheduleResult(file, triggerTurn, RETRY_DELAY_MS);
479
546
  return;
480
547
  }
548
+ if (markReplacedPayload()) return;
481
549
  try {
482
550
  data = markDeliveredNotification(publicResultPath(file), data, runId, Date.now());
483
551
  identityCache.delete(file);
@@ -514,12 +582,16 @@ export function createResultWatcher(
514
582
  scheduleResult(file, triggerTurn, RETRY_DELAY_MS);
515
583
  return;
516
584
  }
517
- if (!ownsSession(sessionId, epoch)) return;
585
+ if (!ownsCompletion(sessionId, completionOwnerId, epoch)) return;
518
586
  if (!removeDeliveredResult(file, sessionId, runId, toolCallId)) scheduleResult(file, triggerTurn, RETRY_DELAY_MS);
519
587
  } catch (error) {
520
- if (!isNotFound(error)) console.error(`Failed to process subagent result file '${resultPath}':`, error);
588
+ if (isAccessDenied(error)) {
589
+ console.error(`Failed to process subagent result file '${resultPath}'; will retry:`, error);
590
+ scheduleResult(file, triggerTurn, RETRY_DELAY_MS);
591
+ } else if (!isNotFound(error)) console.error(`Failed to process subagent result file '${resultPath}':`, error);
521
592
  } finally {
522
593
  processing.delete(file);
594
+ if (rereadReplacedPayload) scheduleResult(file, triggerTurn);
523
595
  }
524
596
  };
525
597
 
@@ -567,6 +639,30 @@ export function createResultWatcher(
567
639
  if (resultScanTimer) timers.clearInterval(resultScanTimer);
568
640
  resultScanTimer = null;
569
641
  };
642
+ const useNativeWatcher = () => shouldUseNativeFsWatch("result-delivery", deps.platform);
643
+ const hasDeliveryDemand = () => {
644
+ try {
645
+ return deps.hasDeliveryDemand?.() === true;
646
+ } catch (error) {
647
+ console.error("Failed to inspect subagent result delivery demand:", error);
648
+ return false;
649
+ }
650
+ };
651
+ const clearResultPoller = () => {
652
+ if (!state.watcherRestartTimer) return;
653
+ timers.clearTimeout(state.watcherRestartTimer);
654
+ timers.clearInterval(state.watcherRestartTimer);
655
+ state.watcherRestartTimer = null;
656
+ };
657
+ const startDemandPolling = () => {
658
+ if (!deliveryActive || useNativeWatcher() || state.watcherRestartTimer) return;
659
+ if (!hasDeliveryDemand()) return;
660
+ state.watcherRestartTimer = timers.setInterval(() => {
661
+ primeExistingResults();
662
+ if (!hasDeliveryDemand()) clearResultPoller();
663
+ }, POLL_INTERVAL_MS);
664
+ state.watcherRestartTimer.unref?.();
665
+ };
570
666
 
571
667
  const startPolling = (reason: unknown) => {
572
668
  state.watcher?.close();
@@ -606,6 +702,10 @@ export function createResultWatcher(
606
702
  timers.clearInterval(state.watcherRestartTimer);
607
703
  state.watcherRestartTimer = null;
608
704
  }
705
+ if (!useNativeWatcher()) {
706
+ startDemandPolling();
707
+ return;
708
+ }
609
709
  try {
610
710
  const watchDir = resolveWatchPath(resultsDir, fsApi.realpathSync.native);
611
711
  state.watcher = fsApi.watch(watchDir, (_event, file) => {
@@ -648,11 +748,7 @@ export function createResultWatcher(
648
748
  deliveryEpoch += 1;
649
749
  state.watcher?.close();
650
750
  state.watcher = null;
651
- if (state.watcherRestartTimer) {
652
- timers.clearTimeout(state.watcherRestartTimer);
653
- timers.clearInterval(state.watcherRestartTimer);
654
- }
655
- state.watcherRestartTimer = null;
751
+ clearResultPoller();
656
752
  clearResultScan();
657
753
  state.resultFileCoalescer.clear();
658
754
  pendingTriggerTurn.clear();
@@ -660,5 +756,5 @@ export function createResultWatcher(
660
756
  identityCache.clear();
661
757
  };
662
758
 
663
- return { startResultWatcher, primeExistingResults, stopResultWatcher };
759
+ return { startResultWatcher, primeExistingResults, stopResultWatcher, refreshResultDelivery: () => { primeExistingResults(); startDemandPolling(); } };
664
760
  }
@@ -1,5 +1,6 @@
1
1
  import * as fs from "node:fs";
2
2
  import type { AsyncRunSummary } from "./async-status.ts";
3
+ import { readAsyncRecoveryDescriptor } from "./async-resume.ts";
3
4
 
4
5
  const INTERCOM_DETACH_ERROR = "detached for intercom coordination";
5
6
 
@@ -15,11 +16,13 @@ function formatIntercomDetachGuidance(run: AsyncRunSummary): string | undefined
15
16
  }
16
17
 
17
18
  export function formatAsyncReviveCommand(run: AsyncRunSummary): string | undefined {
18
- const step = run.steps.find((candidate) => candidate.status === "failed" && candidate.sessionFile && fs.existsSync(candidate.sessionFile));
19
- if (!step) {
20
- if (run.steps.length === 1 && run.sessionFile && fs.existsSync(run.sessionFile)) {
21
- return `subagent({ action: "resume", id: "${run.id}", message: "Continue from the persisted child session and report the result." })`;
22
- }
19
+ const step = run.steps.find((candidate) => candidate.status === "failed");
20
+ const sessionFile = step?.sessionFile ?? (run.steps.length === 1 ? run.sessionFile : undefined);
21
+ if (!step || !sessionFile || !fs.existsSync(sessionFile)) return undefined;
22
+ try {
23
+ const descriptor = readAsyncRecoveryDescriptor(run.asyncDir);
24
+ if (!descriptor || descriptor.sourceRunId !== run.id || descriptor.agent !== step.agent) return undefined;
25
+ } catch {
23
26
  return undefined;
24
27
  }
25
28
  const index = run.steps.length === 1 ? "" : `, index: ${step.index}`;
@@ -2,8 +2,10 @@ import * as fs from "node:fs";
2
2
  import { listAsyncRuns, type AsyncRunSummary } from "./async-status.ts";
3
3
  import { readAsyncRecoveryDescriptor } from "./async-resume.ts";
4
4
  import type { TokenUsage } from "../../shared/types.ts";
5
+ import { parallelHandoffPath, resolveRetainedWorktreeCwd } from "../shared/parallel-handoff.ts";
5
6
 
6
7
  const MAX_RETAINED_CHILDREN = 10;
8
+ const MAX_RETAINED_CHILD_CANDIDATES = 100;
7
9
  const MAX_TASK_SUMMARY_LENGTH = 120;
8
10
 
9
11
  type RetainedChildState = "complete" | "failed" | "paused" | "stopped";
@@ -49,17 +51,25 @@ function retainedSessionFile(sessionFile: string | undefined): RetainedChildResu
49
51
 
50
52
  function childResumability(run: AsyncRunSummary, step: AsyncRunSummary["steps"][number]): RetainedChildResumability {
51
53
  if (run.state === "stopped" || step.status === "stopped") return { state: "not-resumable", reason: "stopped run" };
52
- if (step.runner?.type === "external-cli") return { state: "not-resumable", reason: "external CLI runner" };
54
+ if (step.runner?.type === "external-cli" || step.runner?.type === "external-job") return { state: "not-resumable", reason: "external runner" };
53
55
  const session = retainedSessionFile(step.sessionFile ?? run.sessionFile);
54
56
  if (session.state === "not-resumable") return session;
57
+ let recoveryDescriptor: ReturnType<typeof readAsyncRecoveryDescriptor>;
55
58
  try {
56
- const recoveryDescriptor = readAsyncRecoveryDescriptor(run.asyncDir);
59
+ recoveryDescriptor = readAsyncRecoveryDescriptor(run.asyncDir);
57
60
  if (!recoveryDescriptor) return { state: "not-resumable", reason: "missing recovery descriptor" };
58
61
  if (recoveryDescriptor.sourceRunId !== run.id) return { state: "not-resumable", reason: `recovery descriptor belongs to run ${recoveryDescriptor.sourceRunId}` };
59
62
  if (recoveryDescriptor.agent !== step.agent) return { state: "not-resumable", reason: `recovery descriptor belongs to agent ${recoveryDescriptor.agent}` };
60
63
  } catch (error) {
61
64
  return { state: "not-resumable", reason: `invalid recovery descriptor: ${error instanceof Error ? error.message : String(error)}` };
62
65
  }
66
+ let requiredCwd: string | undefined;
67
+ try {
68
+ requiredCwd = resolveRetainedWorktreeCwd(parallelHandoffPath(run.asyncDir), run.id, step.index) ?? recoveryDescriptor.cwd ?? run.cwd;
69
+ if (!requiredCwd || !fs.statSync(requiredCwd).isDirectory()) return { state: "not-resumable", reason: `required cwd is missing: ${requiredCwd ?? "unknown"}` };
70
+ } catch (error) {
71
+ return { state: "not-resumable", reason: `resume dependency unavailable: ${error instanceof Error ? error.message : String(error)}` };
72
+ }
63
73
  return session;
64
74
  }
65
75
 
@@ -71,7 +81,7 @@ function boundedTaskSummary(value: string | undefined): string {
71
81
  }
72
82
 
73
83
  export function listRetainedChildren(asyncDirRoot: string, sessionId: string): RetainedChild[] {
74
- const children = listAsyncRuns(asyncDirRoot, { sessionId, states: ["complete", "failed", "paused", "stopped"], reconcile: false })
84
+ const children = listAsyncRuns(asyncDirRoot, { sessionId, states: ["complete", "failed", "paused", "stopped"], entryLimit: MAX_RETAINED_CHILD_CANDIDATES, reconcile: false })
75
85
  .flatMap((run) => {
76
86
  if (!run.parentWorkflowRunId || run.steps.length !== 1) return [];
77
87
  if (!isRetainedChildState(run.state)) return [];
@@ -0,0 +1,7 @@
1
+ export const MIN_SAFE_ASYNC_RUN_PREFIX_LENGTH = 8;
2
+ const FULL_ASYNC_RUN_ID_LENGTH = 36;
3
+
4
+ /** Full generated ids and longer exact identities never fall back to a root scan. */
5
+ export function canScanAsyncRunPrefix(id: string): boolean {
6
+ return id.length >= MIN_SAFE_ASYNC_RUN_PREFIX_LENGTH && id.length < FULL_ASYNC_RUN_ID_LENGTH;
7
+ }
@@ -3,7 +3,7 @@ import * as path from "node:path";
3
3
  import { DIRS, type SubagentState } from "../../shared/types.ts";
4
4
  import { readStatus } from "../../shared/utils.ts";
5
5
  import { findAsyncRunPrefixMatches, type AsyncRunLocation } from "./async-resume.ts";
6
- import { resultFilePath, resultFilesForToolCall } from "./result-files.ts";
6
+ import { resultCandidateFilesForToolCall, resultFilePath, resultPayloadPathForIndexedRun } from "./result-files.ts";
7
7
  import { readActiveRunToolCallIndex } from "./active-run-index.ts";
8
8
  import { assertSafeNestedId, findNestedRunMatchesById, type NestedRoute, type NestedRunMatch, type NestedRunResolutionScope } from "../shared/nested-events.ts";
9
9
 
@@ -21,11 +21,12 @@ export interface ResolveSubagentRunIdDeps {
21
21
 
22
22
  function exactAsyncLocation(id: string, asyncDirRoot: string, resultsDir: string): AsyncRunLocation | undefined {
23
23
  const asyncDir = path.join(asyncDirRoot, id);
24
- const resultPath = resultFilePath(resultsDir, id);
25
- if (!fs.existsSync(asyncDir) && !fs.existsSync(resultPath)) return undefined;
24
+ const asyncDirExists = fs.existsSync(asyncDir);
25
+ const resultPath = resultPathFor(resultsDir, id);
26
+ if (!asyncDirExists && !resultPath) return undefined;
26
27
  return {
27
- asyncDir: fs.existsSync(asyncDir) ? asyncDir : null,
28
- resultPath: fs.existsSync(resultPath) ? resultPath : null,
28
+ asyncDir: asyncDirExists ? asyncDir : null,
29
+ resultPath,
29
30
  resolvedId: id,
30
31
  };
31
32
  }
@@ -55,7 +56,7 @@ function readWorkflowResultIdentity(resultPath: string): WorkflowResultIdentity
55
56
  }
56
57
 
57
58
  function resultPathFor(resultsDir: string, runId: string): string | null {
58
- const resultPath = resultFilePath(resultsDir, runId);
59
+ const resultPath = resultPayloadPathForIndexedRun(resultsDir, runId) ?? resultFilePath(resultsDir, runId);
59
60
  return fs.existsSync(resultPath) ? resultPath : null;
60
61
  }
61
62
 
@@ -72,11 +73,12 @@ function indexedToolCallIdAsyncLocations(toolCallId: string, asyncDirRoot: strin
72
73
  const runId = status.runId || entry;
73
74
  byId.set(runId, { asyncDir, resultPath: resultPathFor(resultsDir, runId), resolvedId: runId });
74
75
  }
75
- for (const entry of resultFilesForToolCall(resultsDir, toolCallId)) {
76
- const resultPath = path.join(resultsDir, entry);
76
+ for (const entry of resultCandidateFilesForToolCall(resultsDir, toolCallId)) {
77
+ const runIdFromFile = entry.slice(0, -".json".length);
78
+ const resultPath = resultPayloadPathForIndexedRun(resultsDir, runIdFromFile) ?? path.join(resultsDir, entry);
77
79
  const identity = readWorkflowResultIdentity(resultPath);
78
80
  if (!identity || !toolCallIdMatches(identity.toolCallId, toolCallId)) continue;
79
- const runId = identity.runId ?? identity.id ?? entry.slice(0, -".json".length);
81
+ const runId = identity.runId ?? identity.id ?? runIdFromFile;
80
82
  const asyncDir = path.join(asyncDirRoot, runId);
81
83
  byId.set(runId, { asyncDir: fs.existsSync(asyncDir) ? asyncDir : null, resultPath, resolvedId: runId });
82
84
  }
@@ -108,11 +108,6 @@ function hasExistingSessionFile(value: unknown): value is string {
108
108
  return typeof value === "string" && fs.existsSync(value);
109
109
  }
110
110
 
111
- function formatCheckpointGuidance(runId: string | undefined, checkpoint: AsyncStatus["checkpoint"] | undefined): string | undefined {
112
- if (!runId || !checkpoint || checkpoint.status !== "pending") return undefined;
113
- return `Checkpoint: ${checkpoint.name}${checkpoint.message ? ` — ${checkpoint.message}` : ""}\nApprove: subagent({ action: "approve-checkpoint", id: "${runId}" })\nReject: subagent({ action: "reject-checkpoint", id: "${runId}" })`;
114
- }
115
-
116
111
  function formatResumeGuidance(runId: string | undefined, children: Array<{ agent?: unknown; sessionFile?: unknown; runId?: unknown; workflowKey?: unknown; status?: unknown; activityState?: unknown }>, fallbackSessionFile?: unknown, options: { stopped?: boolean } = {}): string {
117
112
  if (options.stopped) return "Resume: unavailable; stopped runs are not resumable. Start a new run instead.";
118
113
  const knownChildren = children
@@ -210,12 +205,6 @@ function formatRememberedForegroundStatus(run: ForegroundResumeRun): string {
210
205
  else lines.push(`Transcript: subagent({ action: "status", id: "${run.runId}", index: 0, view: "transcript" })`);
211
206
  const detached = run.children.some((child) => child.status === "detached");
212
207
  const resumable = run.children.find((child) => hasExistingSessionFile(child.sessionFile));
213
- if (run.checkpoint?.status === "pending") {
214
- lines.push(`Checkpoint: ${run.checkpoint.name}${run.checkpoint.message ? ` — ${run.checkpoint.message}` : ""}`);
215
- lines.push(`Approve: subagent({ action: "approve-checkpoint", id: "${run.runId}" })`);
216
- lines.push(`Reject: subagent({ action: "reject-checkpoint", id: "${run.runId}" })`);
217
- return lines.join("\n");
218
- }
219
208
  if (detached) {
220
209
  lines.push(`Recovery: reply to the supervisor request first, then wait with subagent_wait({ id: "${run.runId}" }); do not resume or launch a replacement while any child remains detached.`);
221
210
  } else if (resumable) {
@@ -518,6 +507,12 @@ export function inspectSubagentStatus(params: RunStatusParams, deps: RunStatusDe
518
507
  reconciliation.message ? `Diagnosis: ${reconciliation.message}` : undefined,
519
508
  reconciliation.resultPath && fs.existsSync(reconciliation.resultPath) ? `Result: ${reconciliation.resultPath}` : undefined,
520
509
  ].filter((line): line is string => Boolean(line));
510
+ const liveWorkflowControls = status.mode === "workflow" && deps.state?.currentSessionId === status.sessionId && deps.state?.workflowControllers?.has(status.runId)
511
+ ? [...(deps.state?.foregroundControls.values() ?? [])].filter((control) => control.parentWorkflowRunId === status.runId
512
+ && control.sessionId === status.sessionId
513
+ && Boolean(control.workflowSteeringDir && fs.existsSync(control.workflowSteeringDir))
514
+ && (control.activeChildren?.size ?? 0) > 0)
515
+ : [];
521
516
  for (const [index, step] of (status.steps ?? []).entries()) {
522
517
  const stepActivityText = step.status === "running" ? formatActivityLabel(step.lastActivityAt, step.activityState) : undefined;
523
518
  const modelThinking = formatModelThinking(step.model, step.thinking);
@@ -534,28 +529,42 @@ export function inspectSubagentStatus(params: RunStatusParams, deps: RunStatusDe
534
529
  lines.push(` Runner: external-cli (${step.runner.command}${step.runner.args.length ? ` ${step.runner.args.join(" ")}` : ""})`);
535
530
  if (step.externalProcess?.pid !== undefined) lines.push(` Process: ${step.externalProcess.pid}`);
536
531
  if (step.externalProcess) lines.push(` Stdout: ${step.externalProcess.stdoutPath}`, ` Stderr: ${step.externalProcess.stderrPath}`);
532
+ } else if (step.runner?.type === "external-job") {
533
+ lines.push(` Runner: external-job (${step.runner.provider})`);
534
+ if (step.externalJob?.providerJobId) lines.push(` Provider job: ${step.externalJob.providerJobId}`);
535
+ if (step.externalJob?.state) lines.push(` Provider state: ${step.externalJob.state}`);
536
+ if (step.externalJob?.conversationUrl) lines.push(` Conversation: ${step.externalJob.conversationUrl}`);
537
+ if (step.externalJob?.resultArtifactPath) lines.push(` Result artifact: ${step.externalJob.resultArtifactPath}`);
537
538
  }
538
539
  lines.push(...formatNestedRunStatusLines(step.children, { indent: " ", commandHints: true, maxLines: 20 }));
539
540
  const stepOutputPath = path.join(asyncDir, `output-${index}.log`);
540
541
  if (stepOutputPath !== outputPath && fs.existsSync(stepOutputPath)) lines.push(` Output: ${stepOutputPath}`);
541
- if (step.status === "running" && step.runner?.type !== "external-cli") {
542
+ if (step.status === "running" && step.runner?.type !== "external-cli" && step.runner?.type !== "external-job" && status.mode !== "workflow") {
542
543
  lines.push(` Intercom target: ${resolveSubagentIntercomTarget(status.runId, step.agent, index)} (if registered)`);
543
544
  lines.push(` Steer: subagent({ action: "steer", id: "${status.runId}", index: ${index}, message: "..." })`);
544
- } else if (step.status === "running" && step.runner?.type === "external-cli") {
545
- lines.push(" Steer: unavailable; one-shot external CLI runners do not accept live messages.");
545
+ } else if (step.status === "running" && (step.runner?.type === "external-cli" || step.runner?.type === "external-job")) {
546
+ lines.push(" Steer: unavailable; external runners do not accept live messages.");
546
547
  }
547
548
  }
548
549
  const attached = new Set((status.steps ?? []).flatMap((step) => step.children?.map((child) => child.id) ?? []));
549
550
  const unattached = nestedChildren.filter((child) => !attached.has(child.id));
550
551
  lines.push(...formatNestedRunStatusLines(unattached, { indent: "", commandHints: true, maxLines: 20 }));
552
+ if (status.mode === "workflow" && status.state === "running") {
553
+ if (liveWorkflowControls.length === 0) lines.push("Steer: unavailable; no live foreground route is registered in the active session.");
554
+ else for (const control of liveWorkflowControls) {
555
+ for (const index of [...control.activeChildren!.keys()].sort((left, right) => left - right)) {
556
+ lines.push(`Steer live foreground child: subagent({ action: "steer", id: "${control.runId}", index: ${index}, message: "..." })`);
557
+ }
558
+ }
559
+ }
551
560
  if (nestedWarning) lines.push(`Warning: ${nestedWarning}`);
552
561
  if (status.sessionFile) lines.push(`Session: ${status.sessionFile}`);
553
- const allExternal = (status.steps?.length ?? 0) > 0 && status.steps!.every((step) => step.runner?.type === "external-cli");
554
- if (status.state === "running" && !allExternal) lines.push(`Steer running child: subagent({ action: "steer", id: "${status.runId}", message: "..." })`);
562
+ const allExternal = (status.steps?.length ?? 0) > 0 && status.steps!.every((step) => step.runner?.type === "external-cli" || step.runner?.type === "external-job");
563
+ if (status.state === "running" && !allExternal && status.mode !== "workflow") lines.push(`Steer running child: subagent({ action: "steer", id: "${status.runId}", message: "..." })`);
555
564
  if (status.state !== "running") {
556
565
  lines.push(allExternal
557
- ? "Resume: unavailable; one-shot external CLI runners do not persist sessions."
558
- : formatCheckpointGuidance(status.runId, status.checkpoint) ?? formatResumeGuidance(status.runId, status.steps ?? [], status.sessionFile, { stopped: status.state === "stopped" || status.stopped === true }));
566
+ ? "Resume: unavailable; external runners do not persist Pi sessions."
567
+ : formatResumeGuidance(status.runId, status.steps ?? [], status.sessionFile, { stopped: status.state === "stopped" || status.stopped === true }));
559
568
  }
560
569
  if (fs.existsSync(logPath)) lines.push(`Log: ${logPath}`);
561
570
  if (fs.existsSync(eventsPath)) lines.push(`Events: ${eventsPath}`);
@@ -152,7 +152,13 @@ function assertScheduleRoot(root: string, projectCwd: string | undefined, create
152
152
  if (create) fs.mkdirSync(root, { recursive: true, mode: 0o700 });
153
153
  return;
154
154
  }
155
- const projectPath = fs.realpathSync(projectCwd);
155
+ let projectPath: string;
156
+ try {
157
+ projectPath = fs.realpathSync(projectCwd);
158
+ } catch (error) {
159
+ if (!create && (error as NodeJS.ErrnoException).code === "ENOENT") return;
160
+ throw error;
161
+ }
156
162
  let existing = root;
157
163
  while (!fs.existsSync(existing)) {
158
164
  const parent = path.dirname(existing);
@@ -235,7 +241,7 @@ class ScheduleStore {
235
241
  }
236
242
 
237
243
  list(): ScheduleRecord[] {
238
- return this.ids().map((id) => this.get(id));
244
+ return this.ids().map((id) => this.find(id)).filter((record): record is ScheduleRecord => record !== undefined);
239
245
  }
240
246
 
241
247
  get(id: string): ScheduleRecord {
@@ -340,6 +346,8 @@ function executionParams(schedule: ScheduleRecord): SubagentParamsLike {
340
346
  context: "fresh",
341
347
  cwd: schedule.cwd,
342
348
  mission: false,
349
+ // Scheduled fires have no operator watching, so completions must name the origin.
350
+ scheduleOrigin: { id: schedule.id, ...(schedule.name ? { name: schedule.name } : {}) },
343
351
  ...(schedule.timeoutMs === undefined ? {} : { timeoutMs: schedule.timeoutMs }),
344
352
  };
345
353
  }
@@ -419,6 +427,18 @@ export class ScheduledRunManager {
419
427
  return new Set(this.observedAsyncIds);
420
428
  }
421
429
 
430
+ referencedAsyncRunIds(): Set<string> {
431
+ const runIds = new Set(this.observedAsyncIds);
432
+ for (const store of this.stores.values()) {
433
+ for (const scheduleId of store.ids()) {
434
+ for (const run of store.history(scheduleId)) {
435
+ if (run.asyncId) runIds.add(run.asyncId);
436
+ }
437
+ }
438
+ }
439
+ return runIds;
440
+ }
441
+
422
442
  handleAsyncCompletion(payload: unknown): void {
423
443
  if (!payload || typeof payload !== "object") return;
424
444
  const data = payload as { id?: unknown; runId?: unknown; success?: unknown; state?: unknown; summary?: unknown };
@@ -461,7 +481,7 @@ export class ScheduledRunManager {
461
481
  if (this.deps.resolveCapabilityCeiling?.(sessionId)) return textResult("Cannot persist a schedule while a capability ceiling is active.", undefined, undefined, true);
462
482
  if (store.list().length >= resolveMaxPending(this.deps.config)) return textResult(`Schedule limit reached (${resolveMaxPending(this.deps.config)}).`, undefined, undefined, true);
463
483
  const id = validateScheduleId((params.id?.trim() || this.randomId()));
464
- if (store.list().some((item) => item.id === id)) return textResult(`Schedule '${id}' already exists.`, undefined, undefined, true);
484
+ if (store.ids().includes(id)) return textResult(`Schedule '${id}' already exists.`, undefined, undefined, true);
465
485
  const now = this.now();
466
486
  let trigger: ScheduleTrigger;
467
487
  if (at) {
@@ -474,7 +494,7 @@ export class ScheduledRunManager {
474
494
  const schedule: ScheduleRecord = {
475
495
  schemaVersion: 1,
476
496
  id,
477
- name: params.name?.trim() || params.scheduleName?.trim() || targetLabel(target.target!),
497
+ name: params.name?.trim() || targetLabel(target.target!),
478
498
  cwd: path.resolve(params.cwd ?? ctx.cwd),
479
499
  trigger,
480
500
  target: target.target!,
@@ -2,7 +2,7 @@ import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
3
  import { writeAtomicJson } from "../../shared/atomic-json.ts";
4
4
  import { resultFilePath, resultPayloadPathForSessionRun, writeAsyncResultFile } from "./result-files.ts";
5
- import { releaseActiveRunIndex, updateActiveRunIndex } from "./active-run-index.ts";
5
+ import { updateActiveRunIndex } from "./active-run-index.ts";
6
6
  import { readStatus } from "../../shared/utils.ts";
7
7
  import { DIRS, type AsyncParallelGroupStatus, type AsyncStatus, type NestedRunSummary, type SubagentRunMode } from "../../shared/types.ts";
8
8
  import { resolveEffectiveThinking } from "../../shared/model-info.ts";
@@ -17,6 +17,7 @@ interface StartedRunMetadata {
17
17
  runId: string;
18
18
  pid?: number;
19
19
  sessionId?: string;
20
+ completionOwnerId?: string;
20
21
  mode?: SubagentRunMode;
21
22
  agents?: string[];
22
23
  chainStepCount?: number;
@@ -190,6 +191,7 @@ function buildStartedStatus(asyncDir: string, startedRun: StartedRunMetadata, no
190
191
  return {
191
192
  runId: startedRun.runId || path.basename(asyncDir),
192
193
  ...(startedRun.sessionId ? { sessionId: startedRun.sessionId } : {}),
194
+ ...(startedRun.completionOwnerId ? { completionOwnerId: startedRun.completionOwnerId } : {}),
193
195
  mode: startedRun.mode ?? "single",
194
196
  state: "running",
195
197
  pid: startedRun.pid,
@@ -244,6 +246,7 @@ function buildFailedRepair(status: AsyncStatus, asyncDir: string, now: number, r
244
246
  id: runId,
245
247
  agent: resultAgent,
246
248
  mode: status.mode,
249
+ ...(status.completionOwnerId ? { completionOwnerId: status.completionOwnerId } : {}),
247
250
  success: false,
248
251
  state: "failed",
249
252
  summary: message,
@@ -271,7 +274,7 @@ function writeFailedRepair(asyncDir: string, status: AsyncStatus, resultPath: st
271
274
  const repair = buildFailedRepair(status, asyncDir, now, reason);
272
275
  if (repair.result.sessionId) writeAsyncResultFile(resultPath, repair.result);
273
276
  writeAtomicJson(path.join(asyncDir, "status.json"), repair.status);
274
- if (repair.status.processTerminal?.state === "observed") releaseActiveRunIndex(asyncDir);
277
+ updateActiveRunIndex(asyncDir, repair.status.state, repair.status.toolCallId);
275
278
  appendJsonlBestEffort(path.join(asyncDir, "events.jsonl"), {
276
279
  type: "subagent.run.repaired_stale",
277
280
  ts: now,
@@ -365,7 +368,7 @@ export function reconcileAsyncRun(asyncDir: string, options: ReconcileAsyncRunOp
365
368
  : undefined;
366
369
  if (terminalStatus) {
367
370
  writeAtomicJson(path.join(asyncDir, "status.json"), terminalStatus);
368
- if (terminalStatus.processTerminal?.state === "observed") releaseActiveRunIndex(asyncDir);
371
+ updateActiveRunIndex(asyncDir, terminalStatus.state, terminalStatus.toolCallId);
369
372
  return { status: terminalStatus, repaired: true, resultPath: existingResultPath, message: "Existing async result file was used to repair stale running status." };
370
373
  }
371
374
  if (effectiveStatus.displayDismissedAt === undefined) return { status: effectiveStatus, repaired: false, resultPath: existingResultPath };