@selesai/code 0.8.5 → 0.8.7

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 (231) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/dist/core/agent-session.js +13 -1
  3. package/dist/core/extensions/types.d.ts +2 -0
  4. package/dist/core/provider-composer.js +17 -4
  5. package/dist/core/settings-manager.d.ts +2 -0
  6. package/dist/core/settings-manager.js +3 -0
  7. package/dist/core/system-prompt.js +2 -8
  8. package/dist/core/system-prompt.test.js +6 -0
  9. package/dist/core/tools/index.d.ts +1 -0
  10. package/dist/core/tools/index.js +1 -0
  11. package/dist/core/tools/schema-prune.d.ts +16 -0
  12. package/dist/core/tools/schema-prune.js +39 -0
  13. package/dist/core/tools/schema-prune.test.d.ts +1 -0
  14. package/dist/core/tools/schema-prune.test.js +111 -0
  15. package/dist/defaults/models.json +5 -0
  16. package/dist/defaults/settings.json +1 -0
  17. package/dist/extensions/agent-browser.test.ts +178 -2
  18. package/dist/extensions/agent-browser.ts +2 -0
  19. package/dist/extensions/copy-turn.test.ts +229 -0
  20. package/dist/extensions/copy-turn.ts +11 -0
  21. package/dist/extensions/grep-app/index.test.ts +356 -1
  22. package/dist/extensions/grep-app/index.ts +6 -1
  23. package/dist/extensions/handoff-new.test.ts +351 -161
  24. package/dist/extensions/handoff-new.ts +3 -0
  25. package/dist/extensions/inline-skills.test.ts +93 -0
  26. package/dist/extensions/inline-skills.ts +3 -0
  27. package/dist/extensions/model-prompt-injector/config.json +10 -0
  28. package/dist/extensions/model-prompt-injector/index.test.ts +112 -0
  29. package/dist/extensions/model-prompt-injector/index.ts +151 -0
  30. package/dist/extensions/package.json +1 -0
  31. package/dist/extensions/pi-subagents/CHANGELOG.md +158 -90
  32. package/dist/extensions/pi-subagents/UPSTREAM-V0.50-MAPPING.md +172 -0
  33. package/dist/extensions/pi-subagents/agents/builder.md +5 -2
  34. package/dist/extensions/pi-subagents/agents/commentator.md +1 -1
  35. package/dist/extensions/pi-subagents/agents/oracle.md +7 -5
  36. package/dist/extensions/pi-subagents/agents/reviewer.md +2 -2
  37. package/dist/extensions/pi-subagents/agents/scout.md +1 -1
  38. package/dist/extensions/pi-subagents/agents/worker.md +1 -1
  39. package/dist/extensions/pi-subagents/docs/configuration.md +78 -11
  40. package/dist/extensions/pi-subagents/docs/extension-api.md +36 -0
  41. package/dist/extensions/pi-subagents/docs/missions.md +5 -3
  42. package/dist/extensions/pi-subagents/docs/models.md +1 -1
  43. package/dist/extensions/pi-subagents/docs/observability.md +42 -2
  44. package/dist/extensions/pi-subagents/docs/tool-reference.md +20 -3
  45. package/dist/extensions/pi-subagents/docs/workflows.md +4 -4
  46. package/dist/extensions/pi-subagents/install.mjs +2 -2
  47. package/dist/extensions/pi-subagents/package-lock.json +1470 -1472
  48. package/dist/extensions/pi-subagents/package.json +1 -1
  49. package/dist/extensions/pi-subagents/skills/pi-subagents/SKILL.md +1 -1
  50. package/dist/extensions/pi-subagents/skills/pi-subagents/references/constraints-and-recipes.md +5 -4
  51. package/dist/extensions/pi-subagents/skills/pi-subagents/references/execution-controls.md +16 -15
  52. package/dist/extensions/pi-subagents/skills/pi-subagents/references/management-authoring-rpc.md +5 -5
  53. package/dist/extensions/pi-subagents/skills/pi-subagents/references/prompting-and-roles.md +46 -26
  54. package/dist/extensions/pi-subagents/src/agents/agent-serializer.ts +2 -0
  55. package/dist/extensions/pi-subagents/src/agents/agents.ts +37 -12
  56. package/dist/extensions/pi-subagents/src/api/external-runs.ts +174 -84
  57. package/dist/extensions/pi-subagents/src/api/preflight.ts +13 -7
  58. package/dist/extensions/pi-subagents/src/extension/config.ts +59 -0
  59. package/dist/extensions/pi-subagents/src/extension/index.ts +146 -23
  60. package/dist/extensions/pi-subagents/src/extension/public-execution.ts +34 -6
  61. package/dist/extensions/pi-subagents/src/extension/rpc.ts +5 -1
  62. package/dist/extensions/pi-subagents/src/extension/schemas.ts +31 -30
  63. package/dist/extensions/pi-subagents/src/extension/tool-description.ts +10 -9
  64. package/dist/extensions/pi-subagents/src/inspectors/herdr/actions.ts +13 -8
  65. package/dist/extensions/pi-subagents/src/inspectors/herdr/inspector-runner.ts +16 -3
  66. package/dist/extensions/pi-subagents/src/inspectors/herdr/project-panes.ts +2 -6
  67. package/dist/extensions/pi-subagents/src/inspectors/herdr/shell-command.ts +16 -0
  68. package/dist/extensions/pi-subagents/src/intercom/intercom-bridge.ts +5 -4
  69. package/dist/extensions/pi-subagents/src/intercom/native-supervisor-channel.ts +19 -42
  70. package/dist/extensions/pi-subagents/src/missions/goal-driver.ts +3 -1
  71. package/dist/extensions/pi-subagents/src/missions/store.ts +8 -3
  72. package/dist/extensions/pi-subagents/src/runs/background/active-async-capacity.ts +82 -25
  73. package/dist/extensions/pi-subagents/src/runs/background/active-run-index.ts +71 -1
  74. package/dist/extensions/pi-subagents/src/runs/background/async-execution.ts +73 -43
  75. package/dist/extensions/pi-subagents/src/runs/background/async-job-tracker.ts +17 -6
  76. package/dist/extensions/pi-subagents/src/runs/background/async-resume.ts +14 -6
  77. package/dist/extensions/pi-subagents/src/runs/background/async-status-snapshot.ts +277 -0
  78. package/dist/extensions/pi-subagents/src/runs/background/async-status.ts +11 -3
  79. package/dist/extensions/pi-subagents/src/runs/background/chain-root-attachment.ts +2 -2
  80. package/dist/extensions/pi-subagents/src/runs/background/completion-replay.ts +11 -1
  81. package/dist/extensions/pi-subagents/src/runs/background/fleet-view.ts +21 -6
  82. package/dist/extensions/pi-subagents/src/runs/background/notify.ts +5 -2
  83. package/dist/extensions/pi-subagents/src/runs/background/result-files.ts +437 -0
  84. package/dist/extensions/pi-subagents/src/runs/background/result-watcher.ts +224 -42
  85. package/dist/extensions/pi-subagents/src/runs/background/resume-guidance.ts +27 -7
  86. package/dist/extensions/pi-subagents/src/runs/background/retained-children.ts +76 -19
  87. package/dist/extensions/pi-subagents/src/runs/background/run-id-resolver.ts +35 -26
  88. package/dist/extensions/pi-subagents/src/runs/background/run-status.ts +108 -9
  89. package/dist/extensions/pi-subagents/src/runs/background/scheduled-runs.ts +54 -28
  90. package/dist/extensions/pi-subagents/src/runs/background/stale-run-reconciler.ts +27 -13
  91. package/dist/extensions/pi-subagents/src/runs/background/subagent-runner.ts +298 -33
  92. package/dist/extensions/pi-subagents/src/runs/background/subagent-wait.ts +2 -0
  93. package/dist/extensions/pi-subagents/src/runs/background/wait-completions.ts +5 -2
  94. package/dist/extensions/pi-subagents/src/runs/background/wait-subscriptions.ts +2 -1
  95. package/dist/extensions/pi-subagents/src/runs/foreground/async-dismiss-action.ts +2 -1
  96. package/dist/extensions/pi-subagents/src/runs/foreground/chain-execution.ts +16 -0
  97. package/dist/extensions/pi-subagents/src/runs/foreground/execution.ts +219 -15
  98. package/dist/extensions/pi-subagents/src/runs/foreground/foreground-history.ts +7 -4
  99. package/dist/extensions/pi-subagents/src/runs/foreground/prompt-audit.ts +4 -3
  100. package/dist/extensions/pi-subagents/src/runs/foreground/subagent-executor.ts +368 -50
  101. package/dist/extensions/pi-subagents/src/runs/shared/completion-guard.ts +107 -1
  102. package/dist/extensions/pi-subagents/src/runs/shared/external-cli-runner.ts +4 -0
  103. package/dist/extensions/pi-subagents/src/runs/shared/llm-intent-arbiter.ts +39 -23
  104. package/dist/extensions/pi-subagents/src/runs/shared/model-fallback.ts +16 -2
  105. package/dist/extensions/pi-subagents/src/runs/shared/nested-events.ts +66 -62
  106. package/dist/extensions/pi-subagents/src/runs/shared/orca-progress-tabs.ts +376 -0
  107. package/dist/extensions/pi-subagents/src/runs/shared/parallel-utils.ts +2 -0
  108. package/dist/extensions/pi-subagents/src/runs/shared/subagent-control.ts +15 -0
  109. package/dist/extensions/pi-subagents/src/runs/shared/subagent-prompt-runtime.ts +1 -9
  110. package/dist/extensions/pi-subagents/src/runs/shared/subagent-startup-retry.ts +12 -0
  111. package/dist/extensions/pi-subagents/src/runs/shared/tool-timeout.ts +95 -0
  112. package/dist/extensions/pi-subagents/src/shared/agent-stream-options.ts +5 -0
  113. package/dist/extensions/pi-subagents/src/shared/artifacts.ts +0 -4
  114. package/dist/extensions/pi-subagents/src/shared/display-text.ts +50 -0
  115. package/dist/extensions/pi-subagents/src/shared/node-executable.ts +21 -0
  116. package/dist/extensions/pi-subagents/src/shared/session-lineage.ts +71 -0
  117. package/dist/extensions/pi-subagents/src/shared/types.ts +66 -4
  118. package/dist/extensions/pi-subagents/src/slash/slash-commands.ts +34 -25
  119. package/dist/extensions/pi-subagents/src/slash/slash-live-state.ts +4 -0
  120. package/dist/extensions/pi-subagents/src/tui/fleet-status.ts +160 -45
  121. package/dist/extensions/pi-subagents/src/tui/fleet-transcript.ts +1 -48
  122. package/dist/extensions/pi-subagents/src/tui/fleet.ts +129 -17
  123. package/dist/extensions/pi-subagents/src/tui/render.ts +122 -44
  124. package/dist/extensions/pi-subagents/src/watchdog/permission-arbiter.ts +2 -1
  125. package/dist/extensions/pi-subagents/src/watchdog/review.ts +4 -3
  126. package/dist/extensions/pi-subagents/src/workflows/chat-progress.ts +10 -2
  127. package/dist/extensions/pi-subagents/src/workflows/scripted-workflow.ts +272 -76
  128. package/dist/extensions/pi-subagents/src/workflows/workflow-auto-relaunch.ts +28 -0
  129. package/dist/extensions/pi-subagents/test/fixtures/pi-coding-agent-shim/dist/core/extensions/types.d.ts +2 -0
  130. package/dist/extensions/pi-subagents/test/integration/async-execution.test.ts +2 -2
  131. package/dist/extensions/pi-subagents/test/integration/async-job-tracker.test.ts +68 -9
  132. package/dist/extensions/pi-subagents/test/integration/async-status.test.ts +27 -1
  133. package/dist/extensions/pi-subagents/test/integration/chain-execution.test.ts +1 -1
  134. package/dist/extensions/pi-subagents/test/integration/error-handling.test.ts +49 -0
  135. package/dist/extensions/pi-subagents/test/integration/external-cli-runner.test.ts +1 -0
  136. package/dist/extensions/pi-subagents/test/integration/intercom-result-delivery.test.ts +26 -1
  137. package/dist/extensions/pi-subagents/test/integration/orca-progress-tabs.test.ts +144 -0
  138. package/dist/extensions/pi-subagents/test/integration/render-widget.test.ts +26 -0
  139. package/dist/extensions/pi-subagents/test/integration/result-watcher.test.ts +369 -98
  140. package/dist/extensions/pi-subagents/test/integration/single-execution.test.ts +111 -78
  141. package/dist/extensions/pi-subagents/test/integration/slash-commands.test.ts +49 -1
  142. package/dist/extensions/pi-subagents/test/integration/slash-live-state.test.ts +12 -0
  143. package/dist/extensions/pi-subagents/test/support/node-command.ts +15 -0
  144. package/dist/extensions/pi-subagents/test/unit/active-async-capacity.test.ts +50 -0
  145. package/dist/extensions/pi-subagents/test/unit/agent-frontmatter.test.ts +28 -0
  146. package/dist/extensions/pi-subagents/test/unit/agent-overrides.test.ts +61 -0
  147. package/dist/extensions/pi-subagents/test/unit/agent-stream-options.test.ts +14 -0
  148. package/dist/extensions/pi-subagents/test/unit/async-interrupt-action.test.ts +76 -0
  149. package/dist/extensions/pi-subagents/test/unit/async-resume.test.ts +2 -0
  150. package/dist/extensions/pi-subagents/test/unit/async-status-snapshot.test.ts +162 -0
  151. package/dist/extensions/pi-subagents/test/unit/completion-guard.test.ts +209 -10
  152. package/dist/extensions/pi-subagents/test/unit/completion-replay.test.ts +41 -1
  153. package/dist/extensions/pi-subagents/test/unit/external-runs.test.ts +135 -42
  154. package/dist/extensions/pi-subagents/test/unit/fleet-status.test.ts +252 -1
  155. package/dist/extensions/pi-subagents/test/unit/fleet.test.ts +192 -6
  156. package/dist/extensions/pi-subagents/test/unit/handoff-adoption.test.ts +103 -0
  157. package/dist/extensions/pi-subagents/test/unit/herdr-inspector-bootstrap.test.ts +32 -0
  158. package/dist/extensions/pi-subagents/test/unit/herdr-shell-command.test.ts +59 -0
  159. package/dist/extensions/pi-subagents/test/unit/index-child-registration.test.ts +91 -0
  160. package/dist/extensions/pi-subagents/test/unit/intercom-bridge.test.ts +23 -4
  161. package/dist/extensions/pi-subagents/test/unit/llm-intent-arbiter.test.ts +56 -1
  162. package/dist/extensions/pi-subagents/test/unit/mission-goal-driver.test.ts +67 -3
  163. package/dist/extensions/pi-subagents/test/unit/mission-lifecycle.test.ts +1 -1
  164. package/dist/extensions/pi-subagents/test/unit/mission-store.test.ts +14 -0
  165. package/dist/extensions/pi-subagents/test/unit/model-fallback.test.ts +18 -0
  166. package/dist/extensions/pi-subagents/test/unit/native-supervisor-channel.test.ts +2 -2
  167. package/dist/extensions/pi-subagents/test/unit/nested-events.test.ts +25 -2
  168. package/dist/extensions/pi-subagents/test/unit/node-executable.test.ts +26 -0
  169. package/dist/extensions/pi-subagents/test/unit/notify.test.ts +8 -0
  170. package/dist/extensions/pi-subagents/test/unit/orca-progress-tabs.test.ts +348 -0
  171. package/dist/extensions/pi-subagents/test/unit/package-manifest.test.ts +5 -2
  172. package/dist/extensions/pi-subagents/test/unit/pi-args.test.ts +1 -1
  173. package/dist/extensions/pi-subagents/test/unit/pi-coding-agent-dir.test.ts +34 -0
  174. package/dist/extensions/pi-subagents/test/unit/public-execution.test.ts +6 -1
  175. package/dist/extensions/pi-subagents/test/unit/render-helpers.test.ts +92 -0
  176. package/dist/extensions/pi-subagents/test/unit/result-files.test.ts +175 -0
  177. package/dist/extensions/pi-subagents/test/unit/retained-children.test.ts +127 -17
  178. package/dist/extensions/pi-subagents/test/unit/run-id-resolver.test.ts +44 -0
  179. package/dist/extensions/pi-subagents/test/unit/run-status.test.ts +247 -0
  180. package/dist/extensions/pi-subagents/test/unit/scheduled-runs.test.ts +107 -2
  181. package/dist/extensions/pi-subagents/test/unit/schemas.test.ts +5 -8
  182. package/dist/extensions/pi-subagents/test/unit/scripted-workflow.test.ts +456 -4
  183. package/dist/extensions/pi-subagents/test/unit/session-lineage.test.ts +73 -0
  184. package/dist/extensions/pi-subagents/test/unit/stale-run-reconciler.test.ts +128 -0
  185. package/dist/extensions/pi-subagents/test/unit/subagent-control.test.ts +28 -0
  186. package/dist/extensions/pi-subagents/test/unit/subagent-prompt-runtime.test.ts +16 -7
  187. package/dist/extensions/pi-subagents/test/unit/subagent-startup-retry.test.ts +15 -0
  188. package/dist/extensions/pi-subagents/test/unit/subagent-wait.test.ts +79 -15
  189. package/dist/extensions/pi-subagents/test/unit/tool-description.test.ts +12 -12
  190. package/dist/extensions/pi-subagents/test/unit/tool-timeout.test.ts +109 -0
  191. package/dist/extensions/pi-subagents/test/unit/wait-subscriptions.test.ts +34 -0
  192. package/dist/extensions/pi-subagents/test/unit/workflow-auto-relaunch.test.ts +45 -0
  193. package/dist/extensions/pi-subagents/test/unit/workflow-chat-progress.test.ts +94 -1
  194. package/dist/extensions/pi-subagents/test/unit/workflow-launch-params.test.ts +42 -2
  195. package/dist/extensions/ponytail/index.js +11 -0
  196. package/dist/extensions/ponytail/ponytail-config.cjs +2 -0
  197. package/dist/extensions/ponytail/ponytail-instructions.cjs +6 -0
  198. package/dist/extensions/ponytail/test/extension.test.js +274 -140
  199. package/dist/extensions/ponytail/test/helpers.test.js +280 -92
  200. package/dist/extensions/question/index.ts +33 -0
  201. package/dist/extensions/question/question-list.ts +28 -0
  202. package/dist/extensions/question/row-layout.ts +3 -0
  203. package/dist/extensions/question/tests/batch.test.ts +103 -65
  204. package/dist/extensions/question/tests/exp.test.ts +70 -0
  205. package/dist/extensions/question/tests/exp2.test.ts +59 -0
  206. package/dist/extensions/question/tests/exp3.test.ts +47 -0
  207. package/dist/extensions/question/tests/helpers.test.ts +75 -35
  208. package/dist/extensions/question/tests/question-list.test.ts +640 -198
  209. package/dist/extensions/question/tests/row-layout.test.ts +193 -114
  210. package/dist/extensions/question/tests/schemas.test.ts +42 -19
  211. package/dist/extensions/question/tests/selection-mode.test.ts +54 -0
  212. package/dist/extensions/question/tests/shortcuts.test.ts +106 -84
  213. package/dist/extensions/question/tests/wizard.test.ts +771 -50
  214. package/dist/extensions/question/tests/zz-ig3.test.ts +20 -0
  215. package/dist/extensions/question/tests/zz-probe.test.ts +29 -0
  216. package/dist/extensions/rtk.test.ts +180 -1
  217. package/dist/extensions/tokenin-onboarding.ts +3 -0
  218. package/dist/extensions/undo.test.ts +720 -0
  219. package/dist/extensions/undo.ts +6 -0
  220. package/dist/extensions/web-agent-onboarding.test.ts +415 -0
  221. package/dist/extensions/workflow/extension.ts +3 -3
  222. package/dist/extensions/workflow/modes.ts +41 -19
  223. package/dist/skills/pi-subagents/SKILL.md +1 -1
  224. package/dist/skills/pi-subagents/references/constraints-and-recipes.md +5 -4
  225. package/dist/skills/pi-subagents/references/execution-controls.md +16 -15
  226. package/dist/skills/pi-subagents/references/management-authoring-rpc.md +5 -5
  227. package/dist/skills/pi-subagents/references/prompting-and-roles.md +46 -26
  228. package/docs/plans/workflow-autoloop-reference.md +1 -2
  229. package/docs/plans/workflow-handoff-carryover.md +108 -0
  230. package/docs/settings.md +6 -0
  231. package/package.json +4 -1
@@ -22,12 +22,14 @@ import { projectNestedRegistryForRoot, sanitizeSummary } from "../shared/nested-
22
22
  import { resolveWatchPath } from "../../shared/utils.ts";
23
23
  import { recordWaitCompletion } from "./wait-completions.ts";
24
24
  import { MISSION_BINDING_FILE, syncMissionFromAsyncCompletion } from "../../missions/lifecycle.ts";
25
+ import { missionObserverResultCandidateFiles, promotePendingResultFile, removeMissionObserverIndex, removeResultIndex, resultCandidateFilesForSession, resultPayloadPathForIndexedRun, resultPayloadPathForMissionObserverRun, resultPayloadPathForSessionRun, writeAsyncResultFile, writeResultIndexForData } from "./result-files.ts";
25
26
  import type { CompletionNotifier, CompletionNotification } from "./notify.ts";
26
27
 
27
28
  const WATCHER_RESTART_DELAY_MS = 3000;
28
29
  const POLL_INTERVAL_MS = 3000;
29
30
  const HEALTHY_SCAN_INTERVAL_MS = 60_000;
30
31
  const RETRY_DELAY_MS = 100;
32
+ const SLOW_RESULT_SCAN_MS = 500;
31
33
 
32
34
  type ResultWatcherFs = Pick<typeof fs, "existsSync" | "readFileSync" | "unlinkSync" | "readdirSync" | "mkdirSync" | "realpathSync" | "statSync" | "watch">;
33
35
 
@@ -50,6 +52,8 @@ type ResultWatcherDeps = {
50
52
  parseResult?: (raw: string) => ResultFileData;
51
53
  /** External grouped-result transport. Disable when native completion notifications own delivery. */
52
54
  deliverIntercomResults?: boolean;
55
+ /** Coalesces result-file events. Tests can lower this without changing retry timing. */
56
+ coalesceDelayMs?: number;
53
57
  };
54
58
 
55
59
  type ResultFileChild = {
@@ -79,6 +83,7 @@ type ResultFileData = CompletionNotification & {
79
83
  asyncDir?: string;
80
84
  intercomTarget?: string;
81
85
  parallelHandoff?: ParallelHandoffReference;
86
+ notificationDeliveredAt?: unknown;
82
87
  };
83
88
 
84
89
  type ResultFileIdentity = {
@@ -87,6 +92,12 @@ type ResultFileIdentity = {
87
92
  asyncDir?: string;
88
93
  };
89
94
 
95
+ interface ResultScanStats {
96
+ files: number;
97
+ scheduled: number;
98
+ startedAt: number;
99
+ }
100
+
90
101
  function jsonStringProperty(raw: string, property: string): string | undefined {
91
102
  const matches = raw.matchAll(new RegExp(`"${property}"\\s*:\\s*("(?:\\\\.|[^"\\\\])*")`, "g"));
92
103
  let encoded: string | undefined;
@@ -134,6 +145,16 @@ function shouldPoll(error: unknown): boolean {
134
145
  return code === "EMFILE" || code === "ENOSPC";
135
146
  }
136
147
 
148
+ function hasDeliveredNotification(data: ResultFileData): boolean {
149
+ return typeof data.notificationDeliveredAt === "number" && Number.isFinite(data.notificationDeliveredAt);
150
+ }
151
+
152
+ function markDeliveredNotification(resultPath: string, data: ResultFileData, runId: string, now: number): ResultFileData {
153
+ const marked = { ...data, runId, notificationDeliveredAt: now };
154
+ writeAsyncResultFile(resultPath, marked);
155
+ return marked;
156
+ }
157
+
137
158
  /**
138
159
  * Watches persisted async results for the session currently owned by this
139
160
  * runtime. `stopResultWatcher()` revokes ownership before closing resources,
@@ -168,7 +189,24 @@ export function createResultWatcher(
168
189
  const ownsSession = (sessionId: string, epoch: number) => {
169
190
  if (!deliveryActive || epoch !== deliveryEpoch) return false;
170
191
  if (!activeSessionId && state.currentSessionId) activeSessionId = state.currentSessionId;
171
- return activeSessionId === sessionId && state.currentSessionId === sessionId;
192
+ if (activeSessionId !== state.currentSessionId) return false;
193
+ if (sessionId === state.currentSessionId) return true;
194
+ return (state.sessionLineage ?? []).includes(sessionId);
195
+ };
196
+
197
+ const lineageOwnsResult = (identity: ResultFileIdentity, file: string): boolean => {
198
+ const lineage = state.sessionLineage;
199
+ if (!identity.sessionId || !lineage || !lineage.includes(identity.sessionId)) return false;
200
+ // Replay guard: only accept lineage results written around or after the
201
+ // adoption window. The previous session's watcher deleted every result it
202
+ // delivered, so older files are stale leftovers, not missed deliveries.
203
+ const windowStart = state.adoptedResultWindowStart;
204
+ if (windowStart === undefined) return false;
205
+ try {
206
+ return fsApi.statSync(path.join(resultsDir, file)).mtimeMs >= windowStart;
207
+ } catch {
208
+ return false;
209
+ }
172
210
  };
173
211
 
174
212
  const scheduleResult = (file: string, triggerTurn: boolean, delayMs = 0) => {
@@ -177,16 +215,55 @@ export function createResultWatcher(
177
215
  state.resultFileCoalescer.schedule(file, delayMs);
178
216
  };
179
217
 
180
- const inspectResult = (file: string): ResultFileIdentity | undefined => {
181
- const resultPath = path.join(resultsDir, file);
218
+ const publicResultPath = (file: string): string => path.join(resultsDir, file);
219
+ const publicResultFileExists = (file: string): boolean => {
220
+ try {
221
+ return fsApi.statSync(publicResultPath(file)).isFile();
222
+ } catch (error) {
223
+ if (!isNotFound(error)) console.error(`Failed to inspect subagent result file '${publicResultPath(file)}':`, error);
224
+ return false;
225
+ }
226
+ };
227
+ const resultPayloadPath = (file: string, observed?: ReadonlySet<string>): string | undefined => {
228
+ if (file !== path.basename(file) || !file.endsWith(".json")) return undefined;
229
+ const runId = file.replace(/\.json$/i, "");
230
+ const sessionResult = state.currentSessionId ? resultPayloadPathForSessionRun(resultsDir, state.currentSessionId, runId) : undefined;
231
+ if (sessionResult) return sessionResult;
232
+ const observerResult = resultPayloadPathForMissionObserverRun(resultsDir, runId);
233
+ if (observerResult) return observerResult;
234
+ if (observed?.has(runId)) {
235
+ const indexedResult = resultPayloadPathForIndexedRun(resultsDir, runId);
236
+ if (indexedResult) return indexedResult;
237
+ }
238
+ return publicResultFileExists(file) ? publicResultPath(file) : undefined;
239
+ };
240
+ const resultSignature = (file: string, observed?: ReadonlySet<string>): string | undefined => {
241
+ const resultPath = resultPayloadPath(file, observed);
242
+ if (!resultPath) {
243
+ identityCache.delete(file);
244
+ return undefined;
245
+ }
182
246
  try {
183
247
  const stat = fsApi.statSync(resultPath);
184
- const signature = `${stat.size}:${stat.mtimeMs}`;
248
+ if (!stat.isFile()) return undefined;
249
+ return `${resultPath}:${stat.size}:${stat.mtimeMs}`;
250
+ } catch (error) {
251
+ identityCache.delete(file);
252
+ if (!isNotFound(error)) console.error(`Failed to inspect subagent result file '${resultPath}':`, error);
253
+ return undefined;
254
+ }
255
+ };
256
+ const inspectResult = (file: string, knownSignature?: string, observed?: ReadonlySet<string>): { identity: ResultFileIdentity; signature: string } | undefined => {
257
+ const resultPath = resultPayloadPath(file, observed);
258
+ if (!resultPath) return undefined;
259
+ try {
260
+ const signature = knownSignature ?? resultSignature(file, observed);
261
+ if (!signature) return undefined;
185
262
  const cached = identityCache.get(file);
186
- if (cached?.signature === signature) return cached.identity;
263
+ if (cached?.signature === signature) return { identity: cached.identity, signature };
187
264
  const identity = resultFileIdentity(fsApi.readFileSync(resultPath, "utf-8"), file);
188
265
  identityCache.set(file, { signature, identity });
189
- return identity;
266
+ return { identity, signature };
190
267
  } catch (error) {
191
268
  identityCache.delete(file);
192
269
  if (!isNotFound(error)) console.error(`Failed to inspect subagent result file '${resultPath}':`, error);
@@ -203,44 +280,94 @@ export function createResultWatcher(
203
280
  }
204
281
  };
205
282
 
206
- const shouldProcessResult = (file: string, observed?: ReadonlySet<string>): boolean => {
207
- const identity = inspectResult(file);
208
- if (!identity) return false;
283
+ const shouldProcessResult = (file: string, observed?: ReadonlySet<string>, knownSignature?: string): boolean => {
284
+ const inspected = inspectResult(file, knownSignature, observed);
285
+ if (!inspected) return false;
286
+ const { identity } = inspected;
209
287
  // Missing identity stays on the normal parser path so malformed or legacy
210
288
  // files keep their existing diagnostics and compatibility behavior.
211
289
  if (!identity.sessionId) return true;
212
290
  if (identity.sessionId === state.currentSessionId) return true;
291
+ if (lineageOwnsResult(identity, file)) return true;
213
292
  if (identity.asyncDir && fsApi.existsSync(path.join(identity.asyncDir, MISSION_BINDING_FILE))) return true;
214
293
  if (identity.runId && (observed ?? observedRunIds()).has(identity.runId)) return true;
215
294
  return Boolean(deps.observeCompletion && !deps.observedCompletionRunIds);
216
295
  };
217
296
 
297
+ const removeDeliveredResult = (file: string, sessionId: string, runId: string, toolCallId: string | undefined): boolean => {
298
+ try {
299
+ if (publicResultFileExists(file)) fsApi.unlinkSync(publicResultPath(file));
300
+ identityCache.delete(file);
301
+ removeResultIndex(resultsDir, sessionId, runId, toolCallId);
302
+ return true;
303
+ } catch (error) {
304
+ if (!isNotFound(error)) {
305
+ console.error(`Failed to remove delivered subagent result '${publicResultPath(file)}'; will retry:`, error);
306
+ return false;
307
+ }
308
+ return true;
309
+ }
310
+ };
218
311
  const handleResult = async (file: string, triggerTurn: boolean) => {
219
- const resultPath = path.join(resultsDir, file);
220
- if (processing.has(file) || !fsApi.existsSync(resultPath)) return;
221
- if (!shouldProcessResult(file)) return;
312
+ if (processing.has(file)) return;
313
+ let observed: ReadonlySet<string> | undefined;
314
+ if (!shouldProcessResult(file)) {
315
+ const runId = file === path.basename(file) && file.endsWith(".json") ? file.replace(/\.json$/i, "") : undefined;
316
+ observed = observedRunIds();
317
+ if (!runId || !observed.has(runId) || !shouldProcessResult(file, observed)) return;
318
+ }
222
319
  processing.add(file);
320
+ let resultPath = publicResultPath(file);
223
321
  try {
224
- const data = parseResult(fsApi.readFileSync(resultPath, "utf-8"));
322
+ const payloadPath = resultPayloadPath(file, observed);
323
+ if (!payloadPath) return;
324
+ resultPath = payloadPath;
325
+ let raw = fsApi.readFileSync(resultPath, "utf-8");
326
+ let identity = resultFileIdentity(raw, file);
327
+ if (identity.sessionId && identity.runId) {
328
+ const pendingState = promotePendingResultFile(resultsDir, identity.sessionId, identity.runId, file);
329
+ if (pendingState === "promoted") {
330
+ identityCache.delete(file);
331
+ resultPath = publicResultPath(file);
332
+ raw = fsApi.readFileSync(resultPath, "utf-8");
333
+ identity = resultFileIdentity(raw, file);
334
+ } else if (pendingState === "pending") {
335
+ const pendingPath = resultPayloadPathForSessionRun(resultsDir, identity.sessionId, identity.runId);
336
+ if (!pendingPath) {
337
+ scheduleResult(file, triggerTurn, RETRY_DELAY_MS);
338
+ return;
339
+ }
340
+ resultPath = pendingPath;
341
+ raw = fsApi.readFileSync(resultPath, "utf-8");
342
+ identity = resultFileIdentity(raw, file);
343
+ }
344
+ }
345
+ let data = parseResult(raw);
225
346
  if (typeof data.sessionId !== "string" || !data.sessionId) return;
347
+ const sessionId = data.sessionId;
226
348
  const runId = data.runId ?? data.id ?? file.replace(/\.json$/i, "");
349
+ const toolCallId = typeof data.toolCallId === "string" ? data.toolCallId : undefined;
350
+ let observerSucceeded = true;
227
351
  try {
228
352
  syncMissionFromAsyncCompletion({ ...data, runId });
229
353
  } catch (error) {
354
+ observerSucceeded = false;
230
355
  console.error(`Mission completion sync failed for '${resultPath}':`, error);
231
356
  }
232
357
  try {
233
358
  deps.observeCompletion?.({ ...data, runId });
234
359
  } catch (error) {
360
+ observerSucceeded = false;
235
361
  console.error(`Completion observer failed for '${resultPath}':`, error);
236
362
  }
363
+ if (observerSucceeded) removeMissionObserverIndex(resultsDir, runId);
237
364
  const epoch = deliveryEpoch;
238
- if (!ownsSession(data.sessionId, epoch)) return;
365
+ if (!ownsSession(sessionId, epoch)) return;
239
366
  // Recorded before dedupe and before the unlink below so subagent_wait can
240
367
  // use the in-memory record or its bounded durable replay after cleanup.
241
368
  recordWaitCompletion(state, runId, data, Date.now(), completionTtlMs, {
242
369
  resultsDir,
243
- sessionId: data.sessionId,
370
+ sessionId,
244
371
  });
245
372
  const hasExplicitNestedChildren = data.nestedChildren !== undefined;
246
373
  let nestedChildren = compactNestedResultChildren(sanitizeNestedResultChildren(data.nestedChildren, resultPath, "nestedChildren"));
@@ -255,20 +382,17 @@ export function createResultWatcher(
255
382
  }
256
383
 
257
384
  const completionKey = buildCompletionKey(data, `result:${file}`);
385
+ const alreadyDelivered = hasDeliveredNotification(data);
258
386
  const lastSeenAt = state.completionSeen.get(completionKey);
259
387
  if (lastSeenAt !== undefined && Date.now() - lastSeenAt > completionTtlMs) {
260
388
  state.completionSeen.delete(completionKey);
261
389
  } else if (lastSeenAt !== undefined) {
262
- if (!ownsSession(data.sessionId, epoch) || !fsApi.existsSync(resultPath)) return;
263
- try {
264
- fsApi.unlinkSync(resultPath);
265
- identityCache.delete(file);
266
- } catch (error) {
267
- if (!isNotFound(error)) {
268
- console.error(`Failed to remove delivered subagent result '${resultPath}'; will retry:`, error);
269
- scheduleResult(file, triggerTurn, RETRY_DELAY_MS);
270
- }
390
+ if (!observerSucceeded) {
391
+ scheduleResult(file, triggerTurn, RETRY_DELAY_MS);
392
+ return;
271
393
  }
394
+ if (!ownsSession(sessionId, epoch)) return;
395
+ if (!removeDeliveredResult(file, sessionId, runId, toolCallId)) scheduleResult(file, triggerTurn, RETRY_DELAY_MS);
272
396
  return;
273
397
  }
274
398
 
@@ -316,6 +440,17 @@ export function createResultWatcher(
316
440
  };
317
441
  }), nestedChildren);
318
442
 
443
+ if (alreadyDelivered) {
444
+ markSeenWithTtl(state.completionSeen, completionKey, Date.now(), completionTtlMs);
445
+ if (!observerSucceeded) {
446
+ scheduleResult(file, triggerTurn, RETRY_DELAY_MS);
447
+ return;
448
+ }
449
+ if (!ownsSession(sessionId, epoch)) return;
450
+ if (!removeDeliveredResult(file, sessionId, runId, toolCallId)) scheduleResult(file, triggerTurn, RETRY_DELAY_MS);
451
+ return;
452
+ }
453
+
319
454
  const intercomTarget = data.intercomTarget?.trim();
320
455
  let intercomDelivered = false;
321
456
  if (deliverIntercomResults && intercomTarget && triggerTurn) {
@@ -332,7 +467,7 @@ export function createResultWatcher(
332
467
  asyncDir: data.asyncDir,
333
468
  ...(data.parallelHandoff ? { parallelHandoff: data.parallelHandoff } : {}),
334
469
  }));
335
- if (!ownsSession(data.sessionId, epoch)) return;
470
+ if (!ownsSession(sessionId, epoch)) return;
336
471
  if (!intercomDelivered) console.error(`Subagent async grouped result intercom delivery was not acknowledged for '${resultPath}'.`);
337
472
  }
338
473
 
@@ -356,11 +491,19 @@ export function createResultWatcher(
356
491
  })) : [],
357
492
  } : {}),
358
493
  });
359
- if (!ownsSession(data.sessionId, epoch)) return;
494
+ if (!ownsSession(sessionId, epoch)) return;
360
495
  if (!accepted) {
361
496
  scheduleResult(file, triggerTurn, RETRY_DELAY_MS);
362
497
  return;
363
498
  }
499
+ try {
500
+ data = markDeliveredNotification(publicResultPath(file), data, runId, Date.now());
501
+ identityCache.delete(file);
502
+ } catch (error) {
503
+ console.error(`Failed to mark subagent result notification delivered for '${resultPath}'; will retry:`, error);
504
+ scheduleResult(file, triggerTurn, RETRY_DELAY_MS);
505
+ return;
506
+ }
364
507
  markSeenWithTtl(state.completionSeen, completionKey, Date.now(), completionTtlMs);
365
508
  try {
366
509
  pi.events.emit(SUBAGENT_ASYNC_COMPLETE_EVENT, {
@@ -385,16 +528,12 @@ export function createResultWatcher(
385
528
  } catch (error) {
386
529
  console.error(`Completion observer failed for '${resultPath}':`, error);
387
530
  }
388
- if (!ownsSession(data.sessionId, epoch) || !fsApi.existsSync(resultPath)) return;
389
- try {
390
- fsApi.unlinkSync(resultPath);
391
- identityCache.delete(file);
392
- } catch (error) {
393
- if (!isNotFound(error)) {
394
- console.error(`Failed to remove delivered subagent result '${resultPath}'; will retry:`, error);
395
- scheduleResult(file, triggerTurn, RETRY_DELAY_MS);
396
- }
531
+ if (!observerSucceeded) {
532
+ scheduleResult(file, triggerTurn, RETRY_DELAY_MS);
533
+ return;
397
534
  }
535
+ if (!ownsSession(sessionId, epoch)) return;
536
+ if (!removeDeliveredResult(file, sessionId, runId, toolCallId)) scheduleResult(file, triggerTurn, RETRY_DELAY_MS);
398
537
  } catch (error) {
399
538
  if (!isNotFound(error)) console.error(`Failed to process subagent result file '${resultPath}':`, error);
400
539
  } finally {
@@ -406,18 +545,56 @@ export function createResultWatcher(
406
545
  const triggerTurn = pendingTriggerTurn.get(file) !== false;
407
546
  pendingTriggerTurn.delete(file);
408
547
  void handleResult(file, triggerTurn);
409
- }, 50);
548
+ }, deps.coalesceDelayMs ?? 50);
410
549
 
550
+ const logScanStats = (stats: ResultScanStats) => {
551
+ const elapsed = Date.now() - stats.startedAt;
552
+ if (elapsed < SLOW_RESULT_SCAN_MS) return;
553
+ console.error(`Subagent result scan inspected ${stats.files} indexed result file(s), scheduled ${stats.scheduled} in ${elapsed}ms (${resultsDir}).`);
554
+ };
555
+ const indexedResultCandidates = (observed: ReadonlySet<string>): string[] => {
556
+ const files = new Set<string>();
557
+ if (state.currentSessionId) {
558
+ for (const file of resultCandidateFilesForSession(resultsDir, state.currentSessionId)) files.add(file);
559
+ }
560
+ for (const runId of state.asyncJobs.keys()) files.add(`${runId}.json`);
561
+ for (const file of missionObserverResultCandidateFiles(resultsDir)) files.add(file);
562
+ for (const runId of observed) files.add(`${runId}.json`);
563
+ return [...files];
564
+ };
411
565
  const primeExistingResults = (options: { triggerTurn?: boolean } = {}) => {
412
566
  try {
413
567
  const triggerTurn = options.triggerTurn !== false;
414
- fsApi.readdirSync(resultsDir)
415
- .filter((f) => f.endsWith(".json"))
416
- .forEach((file) => {
417
- if (shouldProcessResult(file)) scheduleResult(file, triggerTurn);
418
- });
568
+ const stats: ResultScanStats = { files: 0, scheduled: 0, startedAt: Date.now() };
569
+ const observed = observedRunIds();
570
+ for (const file of indexedResultCandidates(observed)) {
571
+ stats.files += 1;
572
+ const signature = resultSignature(file, observed);
573
+ if (!signature) continue;
574
+ if (!shouldProcessResult(file, observed, signature)) continue;
575
+ stats.scheduled += 1;
576
+ scheduleResult(file, triggerTurn);
577
+ }
578
+ // Selesai fork: during the lineage adoption window, also scan flat
579
+ // result files written directly under the results root by a previous
580
+ // session's runtime, so completions that raced the session switch are
581
+ // adopted instead of left behind. Bounded to the window; outside it,
582
+ // flat files are stale leftovers and only the indexed inbox is scanned.
583
+ if (state.adoptedResultWindowStart !== undefined) {
584
+ const indexed = indexedResultCandidates(observed);
585
+ for (const file of fsApi.readdirSync(resultsDir)) {
586
+ if (!file.endsWith(".json") || indexed.includes(file)) continue;
587
+ stats.files += 1;
588
+ const signature = resultSignature(file, observed);
589
+ if (!signature) continue;
590
+ if (!shouldProcessResult(file, observed, signature)) continue;
591
+ stats.scheduled += 1;
592
+ scheduleResult(file, triggerTurn);
593
+ }
594
+ }
595
+ logScanStats(stats);
419
596
  } catch (error) {
420
- if (!isNotFound(error)) console.error(`Failed to scan subagent result directory '${resultsDir}':`, error);
597
+ if (!isNotFound(error)) console.error(`Failed to scan subagent result index in '${resultsDir}':`, error);
421
598
  }
422
599
  };
423
600
 
@@ -474,6 +651,11 @@ export function createResultWatcher(
474
651
  const fileName = file.toString();
475
652
  if (fileName.endsWith(".json")) {
476
653
  identityCache.delete(fileName);
654
+ try {
655
+ writeResultIndexForData(path.join(resultsDir, fileName), JSON.parse(fsApi.readFileSync(path.join(resultsDir, fileName), "utf-8")) as Record<string, unknown>);
656
+ } catch {
657
+ // The writer may still be renaming the file; handleResult will retry from the normal result path.
658
+ }
477
659
  scheduleResult(fileName, true);
478
660
  }
479
661
  });
@@ -1,6 +1,19 @@
1
1
  import * as fs from "node:fs";
2
2
  import type { AsyncRunSummary } from "./async-status.ts";
3
3
 
4
+ const INTERCOM_DETACH_ERROR = "detached for intercom coordination";
5
+
6
+ function isIntercomDetached(run: AsyncRunSummary): boolean {
7
+ return run.steps.some((step) => step.execution?.status === "detached"
8
+ || step.error?.toLowerCase().includes(INTERCOM_DETACH_ERROR) === true)
9
+ || run.error?.toLowerCase().includes(INTERCOM_DETACH_ERROR) === true;
10
+ }
11
+
12
+ function formatIntercomDetachGuidance(run: AsyncRunSummary): string | undefined {
13
+ if (!isIntercomDetached(run)) return undefined;
14
+ return `Run "${run.id}" detached for intercom coordination. Reply to the supervisor request first, then wait with subagent_wait({ id: "${run.id}" }). Use subagent({ action: "status", id: "${run.id}" }) to recover the result; do not resume or launch a replacement while it remains detached.`;
15
+ }
16
+
4
17
  export function formatAsyncReviveCommand(run: AsyncRunSummary): string | undefined {
5
18
  const step = run.steps.find((candidate) => candidate.status === "failed" && candidate.sessionFile && fs.existsSync(candidate.sessionFile));
6
19
  if (!step) {
@@ -15,19 +28,26 @@ export function formatAsyncReviveCommand(run: AsyncRunSummary): string | undefin
15
28
 
16
29
  export function formatResumeFirstFailedRunDetail(run: AsyncRunSummary): string | undefined {
17
30
  if (run.state !== "failed") return undefined;
31
+ const detachGuidance = formatIntercomDetachGuidance(run);
32
+ if (detachGuidance) return detachGuidance;
18
33
  const command = formatAsyncReviveCommand(run);
19
34
  if (!command) return undefined;
20
35
  return `Resume-first: failed run "${run.id}" has a persisted child session. Revive the original run with ${command} before reporting failure or launching a replacement. Launch a replacement only if revive fails or the user explicitly asks for one.`;
21
36
  }
22
37
 
23
38
  export function formatResumeFirstFailedRunsNote(runs: AsyncRunSummary[]): string {
24
- const resumable = runs
25
- .filter((run) => run.state === "failed")
39
+ const failedRuns = runs.filter((run) => run.state === "failed");
40
+ const detachGuidance = failedRuns
41
+ .map(formatIntercomDetachGuidance)
42
+ .filter((guidance): guidance is string => Boolean(guidance));
43
+ const resumable = failedRuns
44
+ .filter((run) => !isIntercomDetached(run))
26
45
  .map((run) => ({ run, command: formatAsyncReviveCommand(run) }))
27
46
  .filter((entry): entry is { run: AsyncRunSummary; command: string } => Boolean(entry.command));
28
- if (resumable.length === 0) return "";
29
- const guidance = resumable.length === 1
30
- ? `failed run "${resumable[0]!.run.id}" has a persisted child session. Revive the original run with ${resumable[0]!.command}`
31
- : `${resumable.length} failed runs have persisted child sessions. Inspect status and revive each original run before retrying`;
32
- return ` Resume-first: ${guidance} before reporting failure or launching a replacement. Launch a replacement only if revive fails or the user explicitly asks for one.`;
47
+ const resumeGuidance = resumable.length === 0
48
+ ? ""
49
+ : resumable.length === 1
50
+ ? ` Resume-first: failed run "${resumable[0]!.run.id}" has a persisted child session. Revive the original run with ${resumable[0]!.command} before reporting failure or launching a replacement. Launch a replacement only if revive fails or the user explicitly asks for one.`
51
+ : ` Resume-first: ${resumable.length} failed runs have persisted child sessions. Inspect status and revive each original run before reporting failure or launching a replacement. Launch a replacement only if revive fails or the user explicitly asks for one.`;
52
+ return `${detachGuidance.length > 0 ? ` ${detachGuidance.join(" ")}` : ""}${resumeGuidance}`;
33
53
  }
@@ -1,30 +1,68 @@
1
1
  import * as fs from "node:fs";
2
- import { listAsyncRuns } from "./async-status.ts";
2
+ import { listAsyncRuns, type AsyncRunSummary } from "./async-status.ts";
3
+ import { readAsyncRecoveryDescriptor } from "./async-resume.ts";
3
4
  import type { TokenUsage } from "../../shared/types.ts";
4
5
 
5
6
  const MAX_RETAINED_CHILDREN = 10;
6
7
  const MAX_TASK_SUMMARY_LENGTH = 120;
7
8
 
9
+ type RetainedChildState = "complete" | "failed" | "paused" | "stopped";
10
+ type RetainedChildResumability =
11
+ | { state: "resumable"; sessionPath: string }
12
+ | { state: "not-resumable"; reason: string };
13
+
8
14
  export interface RetainedChild {
9
15
  runId: string;
10
16
  parentRunId?: string;
17
+ workflowKey?: string;
18
+ state: RetainedChildState;
11
19
  agent: string;
12
20
  taskSummary: string;
13
21
  completedAt: number;
14
- sessionPath: string;
22
+ resumability: RetainedChildResumability;
23
+ sessionPath?: string;
15
24
  tokenTotals?: TokenUsage;
16
25
  }
17
26
 
18
- function retainedSessionFile(sessionFile: string | undefined): sessionFile is string {
19
- if (!sessionFile?.endsWith(".jsonl")) return false;
27
+ function isRetainedChildState(state: AsyncRunSummary["state"]): state is RetainedChildState {
28
+ return state === "complete" || state === "failed" || state === "paused" || state === "stopped";
29
+ }
30
+
31
+ function isTerminalStepStatus(status: AsyncRunSummary["steps"][number]["status"]): boolean {
32
+ return status === "complete" || status === "completed" || status === "failed" || status === "paused" || status === "stopped";
33
+ }
34
+
35
+ function retainedSessionFile(sessionFile: string | undefined): RetainedChildResumability {
36
+ if (!sessionFile) return { state: "not-resumable", reason: "no persisted session file" };
37
+ if (!sessionFile.endsWith(".jsonl")) return { state: "not-resumable", reason: "persisted session file is not a .jsonl file" };
20
38
  try {
21
39
  const stat = fs.lstatSync(sessionFile);
22
- return stat.isFile() && !stat.isSymbolicLink();
23
- } catch {
24
- return false;
40
+ if (!stat.isFile() || stat.isSymbolicLink()) return { state: "not-resumable", reason: "persisted session file is not a regular file" };
41
+ return { state: "resumable", sessionPath: sessionFile };
42
+ } catch (error) {
43
+ if (typeof error === "object" && error !== null && "code" in error && (error as NodeJS.ErrnoException).code === "ENOENT") {
44
+ return { state: "not-resumable", reason: `persisted session file is missing: ${sessionFile}` };
45
+ }
46
+ return { state: "not-resumable", reason: `persisted session file could not be inspected: ${error instanceof Error ? error.message : String(error)}` };
25
47
  }
26
48
  }
27
49
 
50
+ function childResumability(run: AsyncRunSummary, step: AsyncRunSummary["steps"][number]): RetainedChildResumability {
51
+ 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" };
53
+ const session = retainedSessionFile(step.sessionFile ?? run.sessionFile);
54
+ if (session.state === "not-resumable") return session;
55
+ try {
56
+ const recoveryDescriptor = readAsyncRecoveryDescriptor(run.asyncDir);
57
+ if (!recoveryDescriptor) return { state: "not-resumable", reason: "missing recovery descriptor" };
58
+ if (recoveryDescriptor.sourceRunId !== run.id) return { state: "not-resumable", reason: `recovery descriptor belongs to run ${recoveryDescriptor.sourceRunId}` };
59
+ if (recoveryDescriptor.agent !== step.agent) return { state: "not-resumable", reason: `recovery descriptor belongs to agent ${recoveryDescriptor.agent}` };
60
+ } catch (error) {
61
+ return { state: "not-resumable", reason: `invalid recovery descriptor: ${error instanceof Error ? error.message : String(error)}` };
62
+ }
63
+ return session;
64
+ }
65
+
28
66
  function boundedTaskSummary(value: string | undefined): string {
29
67
  const normalized = value?.replace(/\s+/g, " ").trim() ?? "";
30
68
  return normalized.length > MAX_TASK_SUMMARY_LENGTH
@@ -32,37 +70,56 @@ function boundedTaskSummary(value: string | undefined): string {
32
70
  : normalized;
33
71
  }
34
72
 
35
- export function listRetainedChildren(asyncDirRoot: string, sessionId: string): RetainedChild[] {
36
- return listAsyncRuns(asyncDirRoot, { sessionId, states: ["complete"], reconcile: false })
73
+ export function listRetainedChildren(asyncDirRoot: string, sessionIds: string | string[]): RetainedChild[] {
74
+ const children = listAsyncRuns(asyncDirRoot, { sessionIds: Array.isArray(sessionIds) ? sessionIds : [sessionIds], states: ["complete", "failed", "paused", "stopped"], reconcile: false })
37
75
  .flatMap((run) => {
38
76
  if (!run.parentWorkflowRunId || run.steps.length !== 1) return [];
77
+ if (!isRetainedChildState(run.state)) return [];
39
78
  const step = run.steps[0]!;
40
- if ((step.status !== "complete" && step.status !== "completed") || !retainedSessionFile(step.sessionFile ?? run.sessionFile)) return [];
41
- const completedAt = run.endedAt;
79
+ if (!isTerminalStepStatus(step.status)) return [];
80
+ const completedAt = run.endedAt ?? run.lastUpdate;
42
81
  if (completedAt === undefined) return [];
82
+ const resumability = childResumability(run, step);
43
83
  return [{
44
84
  runId: run.id,
45
85
  ...(run.parentWorkflowRunId ? { parentRunId: run.parentWorkflowRunId } : {}),
86
+ ...(run.workflowKey ? { workflowKey: run.workflowKey } : {}),
87
+ state: run.state,
46
88
  agent: step.agent,
47
89
  taskSummary: boundedTaskSummary(step.description),
48
90
  completedAt,
49
- sessionPath: step.sessionFile ?? run.sessionFile!,
91
+ resumability,
92
+ ...(resumability.state === "resumable" ? { sessionPath: resumability.sessionPath } : {}),
50
93
  ...(step.tokens ?? run.totalTokens ? { tokenTotals: step.tokens ?? run.totalTokens } : {}),
51
94
  }];
52
95
  })
53
- .sort((left, right) => right.completedAt - left.completedAt)
54
- .slice(0, MAX_RETAINED_CHILDREN);
96
+ .sort((left, right) => right.completedAt - left.completedAt);
97
+ return children;
55
98
  }
56
99
 
57
100
  export function formatRetainedChildren(children: RetainedChild[]): string {
58
- if (children.length === 0) return "No completed retained children in the active parent session.";
101
+ if (children.length === 0) return "No retained workflow children in the active parent session. If a retained-writer challenge is required, launch a same-role fallback challenge and label it as fallback.";
102
+ const retained = children.slice(0, MAX_RETAINED_CHILDREN);
103
+ if (!retained.some((child) => child.resumability.state === "resumable")) {
104
+ const resumable = children.slice(MAX_RETAINED_CHILDREN).find((child) => child.resumability.state === "resumable");
105
+ if (resumable && retained.length === MAX_RETAINED_CHILDREN) retained[MAX_RETAINED_CHILDREN - 1] = resumable;
106
+ }
107
+ const hasResumableChild = retained.some((child) => child.resumability.state === "resumable");
59
108
  return [
60
- `Completed retained children (newest first, last ${MAX_RETAINED_CHILDREN}):`,
61
- ...children.flatMap((child) => [
62
- `- ${child.runId} | ${child.agent} | ${new Date(child.completedAt).toISOString()}`,
109
+ `Retained workflow children (up to ${MAX_RETAINED_CHILDREN}; newest first, with a resumable child retained when available):`,
110
+ ...retained.flatMap((child) => [
111
+ `- ${child.runId} | ${child.agent} | ${child.state} | ${new Date(child.completedAt).toISOString()}`,
112
+ ...(child.parentRunId ? [` workflow: ${child.parentRunId}${child.workflowKey ? ` (${child.workflowKey})` : ""}`] : []),
63
113
  ` task: ${child.taskSummary || "(no task summary)"}`,
64
- ` session: ${child.sessionPath}`,
114
+ child.resumability.state === "resumable"
115
+ ? " resumability: resumable"
116
+ : ` resumability: not resumable (${child.resumability.reason})`,
117
+ ...(child.resumability.state === "resumable" ? [
118
+ ` session: ${child.resumability.sessionPath}`,
119
+ ` resume: subagent({ action: "resume", id: "${child.runId}", message: "..." })`,
120
+ ] : []),
65
121
  ...(child.tokenTotals ? [` tokens: input ${child.tokenTotals.input}, output ${child.tokenTotals.output}, total ${child.tokenTotals.total}`] : []),
66
122
  ]),
123
+ ...(hasResumableChild ? [] : ["No resumable retained child is listed. Launch a same-role fallback challenge and label it as fallback."]),
67
124
  ].join("\n");
68
125
  }