@oar-sh/oar 0.9.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 (293) hide show
  1. package/.github/extensions/web-relay/README.md +190 -0
  2. package/.github/extensions/web-relay/extension.mjs +972 -0
  3. package/.github/extensions/web-relay/model-api/model-switching.mjs +338 -0
  4. package/.github/extensions/web-relay/polling/heartbeat.mjs +55 -0
  5. package/.github/extensions/web-relay/polling/polling-loop.mjs +1105 -0
  6. package/.github/extensions/web-relay/runtime/api-client.mjs +34 -0
  7. package/.github/extensions/web-relay/runtime/banner-state.mjs +126 -0
  8. package/.github/extensions/web-relay/runtime/config-loader.mjs +104 -0
  9. package/.github/extensions/web-relay/runtime/debug-log.mjs +27 -0
  10. package/.github/extensions/web-relay/runtime/send-and-wait-errors.mjs +170 -0
  11. package/.github/extensions/web-relay/runtime/session-binding.mjs +78 -0
  12. package/.github/extensions/web-relay/runtime/session-io.mjs +349 -0
  13. package/.github/extensions/web-relay/runtime/session-join-retry.mjs +44 -0
  14. package/.github/extensions/web-relay/runtime/session-registry.mjs +29 -0
  15. package/.github/extensions/web-relay/runtime/session-runtime-manager.mjs +156 -0
  16. package/.github/extensions/web-relay/runtime/session-sync-bridge.mjs +74 -0
  17. package/.github/extensions/web-relay/runtime/worker-websocket-link.mjs +325 -0
  18. package/.github/extensions/web-relay/runtime/workspace-root.mjs +55 -0
  19. package/.github/extensions/web-relay/server-lifecycle/managed-server.mjs +386 -0
  20. package/.github/extensions/web-relay/skills/prompt-context.mjs +160 -0
  21. package/.github/extensions/web-relay/skills/question-bridge.mjs +141 -0
  22. package/.github/extensions/web-relay/skills/question-routing-hooks.mjs +463 -0
  23. package/.github/extensions/web-relay/skills/reasoning-stream.mjs +285 -0
  24. package/.github/extensions/web-relay/skills/subagent-lifecycle.mjs +345 -0
  25. package/.github/extensions/web-relay/skills/tool-activity.mjs +243 -0
  26. package/.github/extensions/web-relay/utils/process-utils.mjs +25 -0
  27. package/.github/extensions/web-relay/utils/token-generation.mjs +58 -0
  28. package/CHANGELOG.md +25 -0
  29. package/LICENSE +21 -0
  30. package/README.md +717 -0
  31. package/bin/bat/oar.bat +46 -0
  32. package/bin/oar.js +665 -0
  33. package/package.json +68 -0
  34. package/server/README.md +1379 -0
  35. package/server/claude-worker/claude-attachments.mjs +134 -0
  36. package/server/claude-worker/claude-preview-tool.mjs +57 -0
  37. package/server/claude-worker/claude-sdk-adapter.mjs +420 -0
  38. package/server/claude-worker/claude-session-process.mjs +2391 -0
  39. package/server/claude-worker/claude-session-test-harness.mjs +236 -0
  40. package/server/claude-worker/claude-session-worker.mjs +186 -0
  41. package/server/claude-worker/claude-transcript-relocator.mjs +189 -0
  42. package/server/claude-worker/claude-turn-publisher.mjs +291 -0
  43. package/server/claude-worker/fixtures/workflow-run-record.json +1 -0
  44. package/server/claude-worker/sdk-message-normalizer.mjs +629 -0
  45. package/server/claude-worker/workflow-progress-digest.mjs +269 -0
  46. package/server/copilot-sdk-runtime.mjs +126 -0
  47. package/server/copilot-worker/copilot-attachments.mjs +97 -0
  48. package/server/copilot-worker/copilot-byok-provider.mjs +121 -0
  49. package/server/copilot-worker/copilot-continuation-signals.mjs +349 -0
  50. package/server/copilot-worker/copilot-plan-board.mjs +110 -0
  51. package/server/copilot-worker/copilot-prompt-context.mjs +86 -0
  52. package/server/copilot-worker/copilot-question-bridge.mjs +216 -0
  53. package/server/copilot-worker/copilot-sdk-adapter.mjs +494 -0
  54. package/server/copilot-worker/copilot-sdk-event-normalizer.mjs +800 -0
  55. package/server/copilot-worker/copilot-sdk-session-process.mjs +1729 -0
  56. package/server/copilot-worker/copilot-sdk-session-worker.mjs +167 -0
  57. package/server/copilot-worker/copilot-sdk-test-harness.mjs +329 -0
  58. package/server/copilot-worker/fixtures/abort-turn.json +588 -0
  59. package/server/copilot-worker/fixtures/ask-user-turn.json +1154 -0
  60. package/server/copilot-worker/fixtures/background-timer-continuation.json +445 -0
  61. package/server/copilot-worker/fixtures/background-timer-turn.json +497 -0
  62. package/server/copilot-worker/fixtures/happy-turn.json +566 -0
  63. package/server/copilot-worker/fixtures/quota-turn.json +647 -0
  64. package/server/copilot-worker/fixtures/reasoning-turn.json +625 -0
  65. package/server/copilot-worker/fixtures/resume-turn.json +700 -0
  66. package/server/copilot-worker/fixtures/subagent-turn.json +1378 -0
  67. package/server/copilot-worker/fixtures/tool-permission-turn.json +1293 -0
  68. package/server/cursor-worker/cursor-ask-user-tool.mjs +83 -0
  69. package/server/cursor-worker/cursor-attachments.mjs +88 -0
  70. package/server/cursor-worker/cursor-context-usage.mjs +85 -0
  71. package/server/cursor-worker/cursor-preview-tool.mjs +44 -0
  72. package/server/cursor-worker/cursor-sdk-adapter.mjs +554 -0
  73. package/server/cursor-worker/cursor-session-worker.mjs +145 -0
  74. package/server/cursor-worker/cursor-subagent-roster.mjs +81 -0
  75. package/server/cursor-worker/cursor-turn-runner.mjs +717 -0
  76. package/server/cursor-worker/sdk-message-normalizer.mjs +485 -0
  77. package/server/db-schema.mjs +932 -0
  78. package/server/features.mjs +106 -0
  79. package/server/grok-worker/acp-client.mjs +434 -0
  80. package/server/grok-worker/acp-host-services.mjs +277 -0
  81. package/server/grok-worker/grok-context-usage.mjs +77 -0
  82. package/server/grok-worker/grok-sdk-adapter.mjs +334 -0
  83. package/server/grok-worker/grok-session-worker.mjs +145 -0
  84. package/server/grok-worker/grok-turn-runner.mjs +523 -0
  85. package/server/grok-worker/sdk-message-normalizer.mjs +311 -0
  86. package/server/migrations/0001-add-structured-answer.mjs +80 -0
  87. package/server/migrations/0002-recent-workspace-roots-path-key.mjs +121 -0
  88. package/server/migrations/0003-push-subscriptions.mjs +87 -0
  89. package/server/public/app/action-confirmations.js +267 -0
  90. package/server/public/app/activity-replay-state.mjs +149 -0
  91. package/server/public/app/api-client.js +1082 -0
  92. package/server/public/app/ask-user-view.js +545 -0
  93. package/server/public/app/attachments-view.js +2112 -0
  94. package/server/public/app/auto-compact-window-options.mjs +59 -0
  95. package/server/public/app/background-tasks-view.mjs +494 -0
  96. package/server/public/app/bootstrap.js +4347 -0
  97. package/server/public/app/claude-auth-ui.js +394 -0
  98. package/server/public/app/claude-thinking-options.mjs +38 -0
  99. package/server/public/app/cli-install-ui.js +455 -0
  100. package/server/public/app/code-copy.mjs +85 -0
  101. package/server/public/app/composer-attachment-cache.mjs +151 -0
  102. package/server/public/app/composer-control-state.mjs +107 -0
  103. package/server/public/app/composer-paste.mjs +132 -0
  104. package/server/public/app/context-tier-options.mjs +68 -0
  105. package/server/public/app/context-usage-view.mjs +338 -0
  106. package/server/public/app/conversation-draft-timestamp-utils.mjs +11 -0
  107. package/server/public/app/conversation-preferences.mjs +91 -0
  108. package/server/public/app/conversation-provider-indicator.mjs +88 -0
  109. package/server/public/app/conversation-view.js +2898 -0
  110. package/server/public/app/cwd-menu-placement.mjs +85 -0
  111. package/server/public/app/cwd-picker.js +605 -0
  112. package/server/public/app/emoji-view.js +101 -0
  113. package/server/public/app/external-link-policy.mjs +80 -0
  114. package/server/public/app/file-preview-navigation.mjs +51 -0
  115. package/server/public/app/font-scaling.js +262 -0
  116. package/server/public/app/git-changes-view.js +339 -0
  117. package/server/public/app/git-diff-model.mjs +109 -0
  118. package/server/public/app/grok-auth-ui.js +387 -0
  119. package/server/public/app/image-downscale.mjs +174 -0
  120. package/server/public/app/in-flight-snapshot.mjs +40 -0
  121. package/server/public/app/infinite-loader.js +164 -0
  122. package/server/public/app/journal-view.js +1141 -0
  123. package/server/public/app/known-cwd-options.mjs +41 -0
  124. package/server/public/app/launch-cwd-path.mjs +23 -0
  125. package/server/public/app/live-message-dedupe.mjs +57 -0
  126. package/server/public/app/message-search-view.js +242 -0
  127. package/server/public/app/model-selector-options.mjs +31 -0
  128. package/server/public/app/model-vision-support.mjs +56 -0
  129. package/server/public/app/new-conversation-model-choice.mjs +67 -0
  130. package/server/public/app/plan-usage-view.mjs +307 -0
  131. package/server/public/app/preview-cards.mjs +273 -0
  132. package/server/public/app/preview-command.mjs +103 -0
  133. package/server/public/app/push-settings.js +370 -0
  134. package/server/public/app/pwa-install.js +456 -0
  135. package/server/public/app/question-schema-view.mjs +71 -0
  136. package/server/public/app/reasoning-effort-labels.mjs +42 -0
  137. package/server/public/app/relay-board-view.js +117 -0
  138. package/server/public/app/relay-dot-state.mjs +69 -0
  139. package/server/public/app/relay-error-ctas.mjs +52 -0
  140. package/server/public/app/relay-prompt-sanitizer.mjs +114 -0
  141. package/server/public/app/relay-thoughts.mjs +98 -0
  142. package/server/public/app/relay-watchdog-policy.mjs +29 -0
  143. package/server/public/app/repo-browser-preferences.mjs +79 -0
  144. package/server/public/app/repo-browser-tree-state.mjs +97 -0
  145. package/server/public/app/router.js +657 -0
  146. package/server/public/app/selection-guard.mjs +93 -0
  147. package/server/public/app/settings-modal.js +1484 -0
  148. package/server/public/app/settings-tabs.js +129 -0
  149. package/server/public/app/slash-autocomplete.mjs +188 -0
  150. package/server/public/app/slash-commands.mjs +183 -0
  151. package/server/public/app/socket-handlers.js +703 -0
  152. package/server/public/app/status-store.mjs +242 -0
  153. package/server/public/app/status-view.mjs +315 -0
  154. package/server/public/app/store.js +1454 -0
  155. package/server/public/app/stream-state.mjs +73 -0
  156. package/server/public/app/streaming-dom-patch.mjs +23 -0
  157. package/server/public/app/sync-outbox.mjs +208 -0
  158. package/server/public/app/tap-actions.js +62 -0
  159. package/server/public/app/thread-order.mjs +54 -0
  160. package/server/public/app/tmux-inspector-view.js +422 -0
  161. package/server/public/app/transcript-separators.mjs +282 -0
  162. package/server/public/app-icon-192.png +0 -0
  163. package/server/public/app-icon-512.png +0 -0
  164. package/server/public/app-icon.svg +4 -0
  165. package/server/public/favicon.ico +0 -0
  166. package/server/public/index.html +5743 -0
  167. package/server/public/manifest.webmanifest +32 -0
  168. package/server/public/sw.js +307 -0
  169. package/server/relay-exit-codes.mjs +4 -0
  170. package/server/relay-self-restart.mjs +170 -0
  171. package/server/relay-tools.md +27 -0
  172. package/server/relay.mjs +1026 -0
  173. package/server/repositories/image-conversation-repository.mjs +317 -0
  174. package/server/repositories/message-repository.mjs +434 -0
  175. package/server/repositories/question-repository.mjs +161 -0
  176. package/server/repositories/session-repository.mjs +320 -0
  177. package/server/repositories/share-visibility-statements.mjs +24 -0
  178. package/server/routes/ask-user-routes.mjs +292 -0
  179. package/server/routes/cache-routes.mjs +44 -0
  180. package/server/routes/git-routes.mjs +92 -0
  181. package/server/routes/messages-routes-test-harness.mjs +111 -0
  182. package/server/routes/messages-routes.mjs +7107 -0
  183. package/server/routes/preview-routes.mjs +108 -0
  184. package/server/routes/push-routes.mjs +59 -0
  185. package/server/routes/relay-board-routes.mjs +268 -0
  186. package/server/routes/sessions-routes.mjs +6150 -0
  187. package/server/server-runtime.mjs +7463 -0
  188. package/server/server.js +24 -0
  189. package/server/services/ask-user-routing-service.mjs +117 -0
  190. package/server/services/cache-rebuild-service.mjs +236 -0
  191. package/server/services/claude-auth-service.mjs +578 -0
  192. package/server/services/claude-context-usage.mjs +240 -0
  193. package/server/services/claude-session-root-service.mjs +228 -0
  194. package/server/services/cli-install-service.mjs +1152 -0
  195. package/server/services/cli-process-runner.mjs +181 -0
  196. package/server/services/cloudflared-tunnel-service.mjs +358 -0
  197. package/server/services/context-snapshot-service.mjs +499 -0
  198. package/server/services/context-usage-view.mjs +160 -0
  199. package/server/services/conversation-preferences-service.mjs +208 -0
  200. package/server/services/cursor-dashboard-usage.mjs +217 -0
  201. package/server/services/cursor-session-root-service.mjs +62 -0
  202. package/server/services/delete-archive-service.mjs +154 -0
  203. package/server/services/drives-path-helpers.mjs +66 -0
  204. package/server/services/fixtures/claude-auth-stub.sh +62 -0
  205. package/server/services/fixtures/cli-install-stub.sh +75 -0
  206. package/server/services/fixtures/grok-stub.sh +86 -0
  207. package/server/services/generated-image-cleanup-service.mjs +114 -0
  208. package/server/services/git-changes-service.mjs +195 -0
  209. package/server/services/github-billing-usage.mjs +133 -0
  210. package/server/services/grok-auth-service.mjs +593 -0
  211. package/server/services/grok-billing-usage.mjs +120 -0
  212. package/server/services/image-context-service.mjs +106 -0
  213. package/server/services/image-operation-service.mjs +135 -0
  214. package/server/services/keyed-mutex-service.mjs +82 -0
  215. package/server/services/mime-sniffer.mjs +118 -0
  216. package/server/services/oar-cli-helpers.mjs +109 -0
  217. package/server/services/oar-state-migration-service.mjs +243 -0
  218. package/server/services/plan-usage-claude.mjs +387 -0
  219. package/server/services/plan-usage-contract.mjs +271 -0
  220. package/server/services/plan-usage-copilot.mjs +421 -0
  221. package/server/services/plan-usage-cursor.mjs +566 -0
  222. package/server/services/plan-usage-grok.mjs +436 -0
  223. package/server/services/plan-usage-service.mjs +487 -0
  224. package/server/services/preview-health-probe.mjs +82 -0
  225. package/server/services/preview-proxy-server.mjs +498 -0
  226. package/server/services/preview-registry-service.mjs +457 -0
  227. package/server/services/preview-static-handler.mjs +237 -0
  228. package/server/services/provider-model-selection.mjs +91 -0
  229. package/server/services/provider-reasoning-effort.mjs +94 -0
  230. package/server/services/push-active-device-service.mjs +57 -0
  231. package/server/services/push-dispatch-service.mjs +459 -0
  232. package/server/services/rate-limit-service.mjs +63 -0
  233. package/server/services/relay-bridge-owner-service.mjs +84 -0
  234. package/server/services/relay-cli-launcher-service.mjs +209 -0
  235. package/server/services/relay-prompt-sanitizer.mjs +137 -0
  236. package/server/services/relay-queue-gate-service.mjs +38 -0
  237. package/server/services/relay-restart-orchestrator-service.mjs +935 -0
  238. package/server/services/relay-singleton-guard.mjs +124 -0
  239. package/server/services/safe-served-content.mjs +57 -0
  240. package/server/services/sdk-session-import-service.mjs +271 -0
  241. package/server/services/sdk-session-sync-service.mjs +207 -0
  242. package/server/services/session-history-refresh-service.mjs +497 -0
  243. package/server/services/session-transcript-service.mjs +419 -0
  244. package/server/services/session-worker-launch-service.mjs +786 -0
  245. package/server/services/session-worker-process-service.mjs +319 -0
  246. package/server/services/session-worker-registry-service.mjs +194 -0
  247. package/server/services/session-worker-stop-service.mjs +141 -0
  248. package/server/services/session-worker-supervisor-service.mjs +898 -0
  249. package/server/services/session-worker-websocket-service.mjs +483 -0
  250. package/server/services/ssh-tunnel-manager-service.mjs +423 -0
  251. package/server/services/status-event-service.mjs +158 -0
  252. package/server/services/tmux-inspector-access-policy.mjs +77 -0
  253. package/server/services/tmux-inspector-socket-service.mjs +191 -0
  254. package/server/services/tmux-inspector-stream-service.mjs +552 -0
  255. package/server/services/tunnel-worker-path-guard.mjs +107 -0
  256. package/server/services/upload-sweep.mjs +60 -0
  257. package/server/services/usage-snapshot-helpers.mjs +209 -0
  258. package/server/services/windows-autostart-service.mjs +115 -0
  259. package/server/services/workspace-root-defaults-service.mjs +34 -0
  260. package/server/services/workspace-root-path-policy.mjs +196 -0
  261. package/server/services/workspace-root-relaunch-service.mjs +132 -0
  262. package/server/services/workspace-symlink-guard.mjs +54 -0
  263. package/server/tools/capture-proxy.mjs +173 -0
  264. package/server/tty-console-bootstrap.mjs +75 -0
  265. package/server/uploads/README.md +36 -0
  266. package/server/uploads/image_input.md +540 -0
  267. package/server/windows-terminal-launcher.mjs +29 -0
  268. package/server/workspace-root.mjs +87 -0
  269. package/shared/ask-user-bridge.mjs +131 -0
  270. package/shared/auto-compact-window.mjs +116 -0
  271. package/shared/background-task-timeout.mjs +52 -0
  272. package/shared/claude-thinking.mjs +101 -0
  273. package/shared/context-window-fallbacks.mjs +148 -0
  274. package/shared/control-poller.mjs +115 -0
  275. package/shared/empty-turn-completion.mjs +20 -0
  276. package/shared/model-catalog-freshness.mjs +11 -0
  277. package/shared/model-descriptors.mjs +118 -0
  278. package/shared/model-id.mjs +147 -0
  279. package/shared/model-refresh.mjs +24 -0
  280. package/shared/openai-reasoning.mjs +13 -0
  281. package/shared/plan-lines.mjs +13 -0
  282. package/shared/preview-instructions.mjs +77 -0
  283. package/shared/preview-tool-core.mjs +191 -0
  284. package/shared/provider-routing.mjs +29 -0
  285. package/shared/question-schema.mjs +315 -0
  286. package/shared/question-timeout.mjs +18 -0
  287. package/shared/stream-emit-gating.mjs +28 -0
  288. package/shared/subagent-run-id.mjs +17 -0
  289. package/shared/thought-cap.mjs +13 -0
  290. package/shared/tool-activity.mjs +98 -0
  291. package/shared/turn-ceiling.mjs +53 -0
  292. package/shared/worker-bootstrap.mjs +44 -0
  293. package/shared/worker-crash-guard.mjs +60 -0
@@ -0,0 +1,2391 @@
1
+ import nodeFs from 'node:fs';
2
+ import nodePath from 'node:path';
3
+
4
+ import { buildClaudeUserContent } from './claude-attachments.mjs';
5
+ import {
6
+ compactBoundaryActivityAction,
7
+ createSdkMessageNormalizer,
8
+ formatApiRetryNotice,
9
+ isSubagentToolName,
10
+ } from './sdk-message-normalizer.mjs';
11
+ import { digestFromRunRecord, digestFromJournal } from './workflow-progress-digest.mjs';
12
+ import { createClaudeSessionRootResolver } from '../services/claude-session-root-service.mjs';
13
+ import {
14
+ startClaudeSession,
15
+ createCanUseTool,
16
+ readContextUsage,
17
+ readPlanUsage,
18
+ normalizeClaudeEffort,
19
+ claudeUltracodeFlagSettings,
20
+ claudeAutoCompactFlagSettings,
21
+ claudeThinkingFlagSettings,
22
+ applyThinkingDisplay,
23
+ normalizeAutoCompactWindow,
24
+ permissionModeForRelayMode,
25
+ } from './claude-sdk-adapter.mjs';
26
+ import { parseThinkingDisplay, parseThinkingEnabled } from '../../shared/claude-thinking.mjs';
27
+ import { relocateClaudeTranscriptForCwd } from './claude-transcript-relocator.mjs';
28
+ import { createClaudeTurnPublisher } from './claude-turn-publisher.mjs';
29
+ import { createAskUserBridge } from '../../shared/ask-user-bridge.mjs';
30
+
31
+ /**
32
+ * Which system-prompt append a relay mode gets (claude-sdk-adapter's
33
+ * MODE_SYSTEM_PROMPT_APPEND). The append is fixed at process spawn, so a mode
34
+ * change across turns only forces a process recycle when the append class
35
+ * differs AND nothing (background tasks, queued continuations) would die with
36
+ * the process; otherwise the turn runs with the previous append and the
37
+ * functionally important switch (permission mode) is applied live.
38
+ */
39
+ function modeAppendClass(relayMode) {
40
+ const mode = String(relayMode || 'agent').trim().toLowerCase();
41
+ return mode === 'ask' || mode === 'autopilot' ? mode : 'none';
42
+ }
43
+
44
+ /** Task types that run a model of their own (vs. bash/monitor/workflow). */
45
+ const AGENT_TASK_TYPES = new Set(['local_agent', 'agent', 'subagent']);
46
+
47
+ // ---------------------------------------------------------------------------
48
+ // Workflow progress sources (CLI 2.1.226 on-disk formats)
49
+ //
50
+ // A `local_workflow` task's tree lives under the native session's project
51
+ // directory: `<sessionDir>/subagents/workflows/<runId>/journal.jsonl` is
52
+ // appended live while the run executes, and `<sessionDir>/workflows/
53
+ // <runId>.json` (the run record, the only place carrying `taskId`) is written
54
+ // only at completion. The poller reads the journal mid-run and switches to the
55
+ // run record the moment it exists.
56
+
57
+ /** Run ids and agent ids are joined into paths, so both are validated first. */
58
+ const WORKFLOW_FS_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
59
+ /**
60
+ * Mid-run nothing on disk maps runId→taskId, so run directories are matched
61
+ * to the task by time instead: the task's startedAt is worker wall clock while
62
+ * dir mtimes come from the filesystem, and this slack absorbs the skew.
63
+ */
64
+ const WORKFLOW_RUN_MATCH_SLACK_MS = 15_000;
65
+ const MAX_WORKFLOW_RECORDS_SCANNED = 25;
66
+ const MAX_WORKFLOW_JOURNAL_BYTES = 2 * 1024 * 1024;
67
+ const MAX_WORKFLOW_RECORD_BYTES = 8 * 1024 * 1024;
68
+ const AGENT_LABEL_READ_BYTES = 8_192;
69
+
70
+ function workflowMtimeMs(candidate) {
71
+ try {
72
+ return Number(nodeFs.statSync(candidate).mtimeMs) || 0;
73
+ } catch {
74
+ return 0;
75
+ }
76
+ }
77
+
78
+ /** Up to `maxBytes` of a file as utf8, or null when it cannot be read. */
79
+ function readBoundedUtf8(filePath, maxBytes) {
80
+ let fd = null;
81
+ try {
82
+ fd = nodeFs.openSync(filePath, 'r');
83
+ const buffer = Buffer.alloc(maxBytes);
84
+ const bytes = nodeFs.readSync(fd, buffer, 0, maxBytes, 0);
85
+ return buffer.toString('utf8', 0, bytes);
86
+ } catch {
87
+ return null;
88
+ } finally {
89
+ if (fd !== null) {
90
+ try { nodeFs.closeSync(fd); } catch {}
91
+ }
92
+ }
93
+ }
94
+
95
+ /** Parse `<sessionDir>/workflows/<runId>.json`, or null. */
96
+ function readWorkflowRunRecord(sessionDir, runId) {
97
+ if (!WORKFLOW_FS_NAME_PATTERN.test(runId)) return null;
98
+ const text = readBoundedUtf8(nodePath.join(sessionDir, 'workflows', `${runId}.json`), MAX_WORKFLOW_RECORD_BYTES);
99
+ if (!text) return null;
100
+ try {
101
+ const record = JSON.parse(text);
102
+ return record && typeof record === 'object' ? record : null;
103
+ } catch {
104
+ return null;
105
+ }
106
+ }
107
+
108
+ /**
109
+ * Find the run record whose `taskId` matches — the only authoritative
110
+ * runId↔taskId join, available once the record is written (completion). Only
111
+ * records touched since the task started (minus slack) are parsed, so a
112
+ * session's pile of past run records costs one stat each, not a parse.
113
+ */
114
+ function scanWorkflowRunRecords({ sessionDir, taskId, startedAt }) {
115
+ const workflowsDir = nodePath.join(sessionDir, 'workflows');
116
+ let entries = [];
117
+ try {
118
+ entries = nodeFs.readdirSync(workflowsDir, { withFileTypes: true });
119
+ } catch {
120
+ return null;
121
+ }
122
+ const candidates = [];
123
+ for (const entry of entries) {
124
+ if (!entry.isFile() || !entry.name.endsWith('.json')) continue;
125
+ const runId = entry.name.slice(0, -'.json'.length);
126
+ if (!WORKFLOW_FS_NAME_PATTERN.test(runId)) continue;
127
+ const modifiedAt = workflowMtimeMs(nodePath.join(workflowsDir, entry.name));
128
+ if (startedAt && modifiedAt < startedAt - WORKFLOW_RUN_MATCH_SLACK_MS) continue;
129
+ candidates.push({ runId, modifiedAt });
130
+ }
131
+ candidates.sort((a, b) => b.modifiedAt - a.modifiedAt);
132
+ for (const candidate of candidates.slice(0, MAX_WORKFLOW_RECORDS_SCANNED)) {
133
+ const record = readWorkflowRunRecord(sessionDir, candidate.runId);
134
+ if (record && String(record.taskId || '').trim() === taskId) return record;
135
+ }
136
+ return null;
137
+ }
138
+
139
+ /**
140
+ * Locate the LIVE run directory for a task, heuristically: among the run
141
+ * directories under `<sessionDir>/subagents/workflows/`, keep those whose own
142
+ * mtime or journal mtime is at or after the task's startedAt minus the slack,
143
+ * and pick the newest. The caller caches the match per taskId; a later run record
144
+ * whose taskId disagrees clears that cache (concurrent workflows can tie the
145
+ * newest-dir pick to the wrong task until their records land).
146
+ */
147
+ function findLiveWorkflowRunId({ sessionDir, startedAt }) {
148
+ const runsDir = nodePath.join(sessionDir, 'subagents', 'workflows');
149
+ let entries = [];
150
+ try {
151
+ entries = nodeFs.readdirSync(runsDir, { withFileTypes: true });
152
+ } catch {
153
+ return '';
154
+ }
155
+ let bestRunId = '';
156
+ let bestModifiedAt = 0;
157
+ for (const entry of entries) {
158
+ if (!entry.isDirectory() || !WORKFLOW_FS_NAME_PATTERN.test(entry.name)) continue;
159
+ const runDir = nodePath.join(runsDir, entry.name);
160
+ const modifiedAt = Math.max(
161
+ workflowMtimeMs(runDir),
162
+ workflowMtimeMs(nodePath.join(runDir, 'journal.jsonl')),
163
+ );
164
+ if (startedAt && modifiedAt < startedAt - WORKFLOW_RUN_MATCH_SLACK_MS) continue;
165
+ if (modifiedAt > bestModifiedAt) {
166
+ bestModifiedAt = modifiedAt;
167
+ bestRunId = entry.name;
168
+ }
169
+ }
170
+ return bestRunId;
171
+ }
172
+
173
+ /** Parsed lines of a run directory's journal.jsonl, or null when unreadable. */
174
+ function readWorkflowJournal(runDir) {
175
+ const text = readBoundedUtf8(nodePath.join(runDir, 'journal.jsonl'), MAX_WORKFLOW_JOURNAL_BYTES);
176
+ if (text === null) return null;
177
+ const entries = [];
178
+ for (const line of text.split('\n')) {
179
+ const trimmed = line.trim();
180
+ if (!trimmed) continue;
181
+ try {
182
+ entries.push(JSON.parse(trimmed));
183
+ } catch {
184
+ // A half-appended (or truncated-by-cap) line parses on a later poll.
185
+ }
186
+ }
187
+ return entries;
188
+ }
189
+
190
+ /** JSON string body → text, tolerating a fragment cut mid-escape-sequence. */
191
+ function unescapeJsonStringFragment(fragment) {
192
+ for (let cut = 0; cut < 6 && cut < fragment.length; cut += 1) {
193
+ try {
194
+ return JSON.parse(`"${fragment.slice(0, fragment.length - cut)}"`);
195
+ } catch {
196
+ // The tail may be a split escape sequence; trim one char and retry.
197
+ }
198
+ }
199
+ return '';
200
+ }
201
+
202
+ /**
203
+ * The live label for one workflow agent: the first line of its
204
+ * `agent-<agentId>.jsonl` is `{"type":"user","message":{role,content},...}`
205
+ * where `message.content` is the agent's prompt — its first ~160 chars are
206
+ * the best mid-run label (the run record's `label` only exists at
207
+ * completion). Only the first ~8KB is read; a first line longer than that is
208
+ * recovered via a raw `"content":"…"` match on the chunk.
209
+ */
210
+ function readWorkflowAgentLabel(runDir, agentId) {
211
+ if (!WORKFLOW_FS_NAME_PATTERN.test(agentId)) return '';
212
+ const chunk = readBoundedUtf8(nodePath.join(runDir, `agent-${agentId}.jsonl`), AGENT_LABEL_READ_BYTES);
213
+ if (!chunk) return '';
214
+ const newlineIndex = chunk.indexOf('\n');
215
+ const firstLine = newlineIndex === -1 ? chunk : chunk.slice(0, newlineIndex);
216
+ let content = null;
217
+ try {
218
+ content = JSON.parse(firstLine)?.message?.content;
219
+ } catch {
220
+ const match = /"content"\s*:\s*"((?:[^"\\]|\\.){1,400})/.exec(firstLine);
221
+ if (match) content = unescapeJsonStringFragment(match[1]);
222
+ }
223
+ if (Array.isArray(content)) {
224
+ content = content.find((block) => block?.type === 'text')?.text;
225
+ }
226
+ if (typeof content !== 'string') return '';
227
+ return content.replace(/\s+/g, ' ').trim().slice(0, 160);
228
+ }
229
+
230
+ /**
231
+ * Default session-dir resolution for the workflow poller: the same
232
+ * `<configRoot>/projects/<slug(cwd)>/<nativeSessionId>/` derivation the
233
+ * transcript relocator uses, via the shared session-root resolver (which owns
234
+ * the cwd slug, the CLAUDE_CONFIG_DIR/~/.claude root order, the scan fallback,
235
+ * and caching). Injectable so tests can point the poller at a temp dir.
236
+ */
237
+ function createDefaultWorkflowSessionDirResolver() {
238
+ let resolver = null;
239
+ return ({ nativeSessionId, cwd }) => {
240
+ if (!resolver) resolver = createClaudeSessionRootResolver();
241
+ const resolved = resolver.resolveClaudeSessionRoot({
242
+ claudeNativeSessionId: nativeSessionId,
243
+ workspaceRootPath: cwd,
244
+ });
245
+ return resolved?.sessionRootPath || '';
246
+ };
247
+ }
248
+
249
+ /** The text a user content payload streams as — the replay-matching key. */
250
+ function contentText(content) {
251
+ if (typeof content === 'string') return content;
252
+ if (!Array.isArray(content)) return '';
253
+ return content
254
+ .filter((block) => block?.type === 'text')
255
+ .map((block) => String(block.text || ''))
256
+ .join('\n');
257
+ }
258
+
259
+ /**
260
+ * A turn-opening user message in the SDK stream: top-level (not a subagent's),
261
+ * carrying prompt text rather than tool_result blocks. Both our own pushed
262
+ * messages and the CLI's task-notification continuations replay this way.
263
+ */
264
+ function turnOpeningUserSignature(sdkMessage) {
265
+ if (sdkMessage?.type !== 'user' || sdkMessage?.parent_tool_use_id) return null;
266
+ const content = sdkMessage?.message?.content;
267
+ if (typeof content === 'string') return content;
268
+ if (!Array.isArray(content)) return null;
269
+ if (content.some((block) => block?.type === 'tool_result')) return null;
270
+ // Unlike turnOpeningUserText this keeps '' — an attachment-only message
271
+ // replays with an empty text block, and the absorbed-steering match must
272
+ // still recognize it (expectedText is '' for those too).
273
+ return contentText(content);
274
+ }
275
+
276
+ function turnOpeningUserText(sdkMessage) {
277
+ const text = turnOpeningUserSignature(sdkMessage);
278
+ return text || null;
279
+ }
280
+
281
+ /**
282
+ * The CLI synthesizes a user-role message when a background task settles. The
283
+ * SDK stamps its provenance structurally: `SDKUserMessage.origin` is an
284
+ * `SDKMessageOrigin`, and a settled task's continuation carries
285
+ * `{ kind: 'task-notification' }` (sdk.d.ts). The compaction's own summary
286
+ * replay carries no `origin` at all (incident transcript row 1514).
287
+ *
288
+ * The `<task-notification>` opening tag — which the CLI documents to the model
289
+ * as the way to recognize these ("they look like user messages but are not") —
290
+ * is kept as a fallback for emitters that predate the field. It can only push
291
+ * the answer toward "not the compaction's replay", which is the safe
292
+ * direction: not adopting merely restores the pre-fix behaviour, while a wrong
293
+ * adoption closes the user's row with another turn's answer.
294
+ */
295
+ const TASK_NOTIFICATION_TAG_PREFIX = '<task-notification';
296
+
297
+ /**
298
+ * The first text block's raw text, or null when there is none to read. Real
299
+ * notification rows carry `content` as a plain string (242 of them on disk),
300
+ * not a block array, so both spellings are read.
301
+ */
302
+ function firstTextBlock(sdkMessage) {
303
+ const content = sdkMessage?.message?.content;
304
+ if (typeof content === 'string') return content;
305
+ if (!Array.isArray(content)) return null;
306
+ const first = content[0];
307
+ return first?.type === 'text' ? String(first.text || '') : null;
308
+ }
309
+
310
+ /** A settled background task's continuation, by origin or by leading tag. */
311
+ function isTaskNotificationReplay(sdkMessage) {
312
+ if (String(sdkMessage?.origin?.kind || '') === 'task-notification') return true;
313
+ const text = firstTextBlock(sdkMessage);
314
+ return text !== null && text.trimStart().startsWith(TASK_NOTIFICATION_TAG_PREFIX);
315
+ }
316
+
317
+ /**
318
+ * Whether a turn the CLI opened by itself may be the compaction re-opening a
319
+ * delivered turn, rather than something else the CLI decided to say.
320
+ *
321
+ * Tested POSITIVELY, and it has to be: `SDKMessageOrigin` is a nine-member
322
+ * union (`human`, `channel`, `peer`, `task-notification`, `coordinator`,
323
+ * `unclassified`, `observer`, …), so "not a task notification" is fail-OPEN —
324
+ * a cross-session `peer` message or an `auto-continuation` would be read as
325
+ * the compaction's summary and would permanently steal the delivered row. The
326
+ * compact summary is the CLI's own synthesized replay and carries NO `origin`
327
+ * at all (incident transcript row 1514: `origin=None`), so any stamped
328
+ * provenance disqualifies it. The `<task-notification>` tag stays as a second
329
+ * disqualifier for emitters that predate `origin`.
330
+ *
331
+ * Failing closed costs only the pre-fix behaviour (the row waits for its own
332
+ * replay); failing open closes a user's row with another turn's answer.
333
+ */
334
+ function isCompactionReplayCandidate(sdkMessage) {
335
+ if (firstTextBlock(sdkMessage) === null) return false;
336
+ if (sdkMessage?.origin !== undefined && sdkMessage?.origin !== null) return false;
337
+ return !isTaskNotificationReplay(sdkMessage);
338
+ }
339
+
340
+ /**
341
+ * The bookkeeping result a resumed session emits after replaying an
342
+ * orphaned-task notification: zero API work, nothing to publish. Detected at
343
+ * the process level (in addition to the normalizer's own skip) so it can never
344
+ * open or close a turn context.
345
+ */
346
+ function isPhantomResult(sdkMessage) {
347
+ return sdkMessage?.type === 'result'
348
+ && sdkMessage.subtype === 'success'
349
+ && sdkMessage.is_error !== true
350
+ && Number(sdkMessage.num_turns) === 0
351
+ && Number(sdkMessage.duration_api_ms) === 0;
352
+ }
353
+
354
+ /**
355
+ * Run a relay conversation against ONE persistent Claude CLI process.
356
+ *
357
+ * The pre-existing runner spawned a CLI per turn and held its stdin open
358
+ * ("input gate linger") while background subagents ran — which meant
359
+ * backgrounded Bash/Monitor tasks died seconds after each reply and their
360
+ * "you will be notified" continuations never happened (incident conv
361
+ * `2353a9eb`). Here the process outlives turns: delivered relay messages are
362
+ * pushed into the same streaming-input query, background tasks keep running
363
+ * between turns, and the continuation turns the CLI dequeues when a task
364
+ * settles are published as their own relay turns (synthetic queue rows via
365
+ * POST /api/continuation-turn).
366
+ *
367
+ * Lifecycle: the process stays alive while a turn is active or queued, any
368
+ * background task is live, a settled task's continuation is still due, or a
369
+ * canUseTool round-trip (question / permission) is pending. With none of
370
+ * those it idles out after `idleShutdownMs` and the CLI exits; the next
371
+ * delivered message respawns it with `resume`. `getBackgroundTaskTimeoutMs`
372
+ * (0 = unlimited) caps how long tasks alone may hold the process — on expiry
373
+ * every live task is stopped via the SDK and the wind-down proceeds through
374
+ * the normal notification/continuation path.
375
+ */
376
+ export function createClaudeSessionRunner({
377
+ api,
378
+ sdkSessionId,
379
+ cwd,
380
+ defaultModel = '',
381
+ controlPoller,
382
+ pathToClaudeCodeExecutable = '',
383
+ startClaudeSessionImpl = startClaudeSession,
384
+ readContextUsageImpl = readContextUsage,
385
+ readPlanUsageImpl = readPlanUsage,
386
+ relocateTranscriptImpl = relocateClaudeTranscriptForCwd,
387
+ idleShutdownMs = 10 * 60_000,
388
+ getBackgroundTaskTimeoutMs = () => 0,
389
+ // The per-conversation auto-compact window (token count, null = Auto). Read
390
+ // per turn rather than captured, so a slider change picked up on the next
391
+ // delivery reaches a process that is already running.
392
+ getAutoCompactWindow = () => null,
393
+ // The per-conversation thinking state ({enabled, display}); read per turn
394
+ // like the window, so a settings change reaches a running process on its
395
+ // next delivery.
396
+ getThinking = () => ({ enabled: null, display: '' }),
397
+ lifecyclePollMs = 5_000,
398
+ // A settled task's continuation normally begins within ~1s; when nothing
399
+ // arrives inside this window the notification was silent (skip_transcript)
400
+ // and must stop pinning the process.
401
+ notificationGraceMs = 60_000,
402
+ // How long a delivered entry may sit unattached (no active turn, no live
403
+ // tasks, quiet stream) before the watchdog fails its row over. Must exceed
404
+ // the slowest cold start observed; the primary absorbed-steering fix in
405
+ // resolveContext should make this fire only for unknown variants.
406
+ pendingDeliveredTimeoutMs = 5 * 60_000,
407
+ // Backstop for the compaction hold. A compaction is announced ONCE by
408
+ // `status: 'compacting'` (the CLI's periodic re-emit is gated on the
409
+ // remote-control client's activity callback, which the plain SDK `query()`
410
+ // never registers) and is released by the boundary, by a terminating status,
411
+ // or by the process dying — this cap only matters when none of those ever
412
+ // arrives. It gates the pending-delivered watchdog, idle shutdown and the
413
+ // mode-change recycle, so it is sized far above any plausible compaction
414
+ // (measured: 133 s for 614k tokens) rather than tightly: releasing late
415
+ // leaves an idle CLI up a few minutes longer, releasing early kills a live
416
+ // compaction mid-flight.
417
+ compactionStaleMs = 10 * 60_000,
418
+ // How long after a compaction signal a delivered message may still adopt the
419
+ // turn the CLI opens by itself. The replay follows the boundary within
420
+ // milliseconds; this only has to outlast that gap, and the window is spent
421
+ // by the first turn to open regardless.
422
+ compactReplayAdoptionMs = 60_000,
423
+ continuationRetryDelayMs = 500,
424
+ workflowPollMs = 2_000,
425
+ resolveWorkflowSessionDirImpl = null,
426
+ askUserBridgeOptions = {},
427
+ dbg = () => {},
428
+ } = {}) {
429
+ const publisher = createClaudeTurnPublisher({
430
+ api,
431
+ dbg,
432
+ takeWorkflowRuns: () => drainSettledWorkflowRuns(),
433
+ });
434
+ const resolveWorkflowSessionDir = resolveWorkflowSessionDirImpl || createDefaultWorkflowSessionDirResolver();
435
+ let claudeNativeSessionId = '';
436
+ let proc = null;
437
+
438
+ async function persistNativeSessionId(conversationId, sessionId) {
439
+ const normalized = String(sessionId || '').trim();
440
+ if (!normalized || normalized === claudeNativeSessionId) return;
441
+ try {
442
+ await api('POST', '/api/claude-native-session', {
443
+ conversationId,
444
+ claudeNativeSessionId: normalized,
445
+ });
446
+ // Only cache after the server accepted it, so a failed persist is
447
+ // retried on the next init — resume across worker restarts depends on
448
+ // the server-side copy.
449
+ claudeNativeSessionId = normalized;
450
+ } catch (error) {
451
+ dbg('claude native session persist failed', error?.message || String(error));
452
+ }
453
+ }
454
+
455
+ function resolvePerTurnModel(message) {
456
+ // Per-message model wins so the composer can switch Claude models between
457
+ // turns; the conversation's provider model and worker default are fallbacks.
458
+ const requestedModel = String(message.model || '').trim();
459
+ const perTurnModel = requestedModel.toLowerCase() !== 'auto' && requestedModel.toLowerCase().startsWith('claude-')
460
+ ? requestedModel
461
+ : '';
462
+ return perTurnModel
463
+ || String(message.providerModel || '').trim()
464
+ || defaultModel;
465
+ }
466
+
467
+ function getActiveQueueMessageId() {
468
+ if (!proc) return '';
469
+ return String(proc.activeCtx?.message?.id || proc.pendingDelivered[0]?.ctx.message?.id || '');
470
+ }
471
+
472
+ // Every queue row this worker currently owes work for: the running turn
473
+ // (delivered or continuation) plus any delivered message queued behind it.
474
+ // The heartbeat reports all of them so the server's owner-recovery never
475
+ // replays a row the process still holds.
476
+ function getActiveQueueMessageIds() {
477
+ if (!proc) return [];
478
+ return [
479
+ proc.activeCtx?.message?.id,
480
+ ...proc.pendingDelivered.map((entry) => entry.ctx.message?.id),
481
+ ].map((id) => String(id || '').trim()).filter(Boolean);
482
+ }
483
+
484
+ function isTurnActive() {
485
+ return Boolean(proc && (proc.activeCtx || proc.pendingDelivered.length));
486
+ }
487
+
488
+ // ---------------------------------------------------------------------------
489
+ // Turn contexts
490
+
491
+ function createContext(kind, message) {
492
+ return {
493
+ kind, // 'delivered' | 'continuation'
494
+ message,
495
+ normalizer: createSdkMessageNormalizer(),
496
+ state: {
497
+ result: null,
498
+ resultTexts: [],
499
+ lastStreamedText: '',
500
+ responseModel: '',
501
+ contextUsage: null,
502
+ planUsage: null,
503
+ modelUsage: null,
504
+ },
505
+ planBoardPosted: false,
506
+ // Set only on a context attached by post-compaction adoption, and only
507
+ // until that turn produces output — see handBackAdoptedContext.
508
+ adoptedFromCompaction: false,
509
+ // The pendingDelivered entry this context was attached from, kept so a
510
+ // handed-back adoption can restore it verbatim.
511
+ deliveredEntry: null,
512
+ // When the provisional adoption's quiet clock started (see
513
+ // reapSilentProvisionalAdoption).
514
+ provisionalSince: 0,
515
+ interrupted: false,
516
+ discarded: false,
517
+ registered: kind === 'delivered',
518
+ bufferedActions: [],
519
+ controlState: null,
520
+ finalized: false,
521
+ resolveDone: null,
522
+ rejectDone: null,
523
+ done: null,
524
+ };
525
+ }
526
+
527
+ function createDeliveredContext(message) {
528
+ const ctx = createContext('delivered', message);
529
+ ctx.done = new Promise((resolve, reject) => {
530
+ ctx.resolveDone = resolve;
531
+ ctx.rejectDone = reject;
532
+ });
533
+ return ctx;
534
+ }
535
+
536
+ function activateContext(ctx) {
537
+ proc.activeCtx = ctx;
538
+ proc.lastBoundary = null;
539
+ proc.continuationInitPending = false;
540
+ proc.notificationPendingAt = 0;
541
+ proc.taskSettledAt = 0;
542
+ if (proc.initModel && !ctx.state.responseModel) ctx.state.responseModel = proc.initModel;
543
+ ctx.controlState = controlPoller?.start?.({
544
+ queueMessageId: ctx.message?.id || '',
545
+ onAbortTurn: () => interruptActiveTurn(ctx),
546
+ }) || null;
547
+ // One-shot: a compaction's replay window is spent by the turn that
548
+ // consumes it, and a turn that opened for any other reason retires it too
549
+ // — a boundary must never be able to adopt a second turn (see
550
+ // resolveContext).
551
+ proc.compactReplayUntil = 0;
552
+ // Orphan/settled-task notification lines that arrived between turns belong
553
+ // to the turn they triggered.
554
+ const carried = proc.pendingActivities.splice(0);
555
+ if (carried.length) {
556
+ const actions = carried.map((entry) => (typeof entry === 'string'
557
+ ? { channel: 'activity', payload: { text: entry, subagentRunId: null } }
558
+ : entry));
559
+ if (ctx.registered) {
560
+ (async () => {
561
+ for (const action of actions) await publisher.dispatchAction(ctx.message, action, ctx.state);
562
+ })().catch(() => {});
563
+ } else {
564
+ ctx.bufferedActions.push(...actions);
565
+ }
566
+ }
567
+ }
568
+
569
+ /**
570
+ * Attach the delivered entry at `index` — the head unless a replay matched a
571
+ * later one. The CLI normally dequeues in order, but two messages pushed
572
+ * back to back can replay out of order, and pairing by position alone would
573
+ * answer each with the other's turn.
574
+ */
575
+ function attachDeliveredContext(index = 0) {
576
+ const [entry] = proc.pendingDelivered.splice(index, 1);
577
+ entry.ctx.deliveredEntry = entry;
578
+ activateContext(entry.ctx);
579
+ return entry.ctx;
580
+ }
581
+
582
+ /**
583
+ * Undo a post-compaction adoption: this turn was never the delivered
584
+ * message's, and nothing has published on it (the provisional flag is
585
+ * cleared by the turn's first real output, so every caller runs strictly
586
+ * before that).
587
+ *
588
+ * The entry goes back to the HEAD of the queue — it is still the next thing
589
+ * the CLI owes a replay for, and anything queued behind it must stay behind
590
+ * it — with its unattached clock reset, exactly as `armCompactReplayAdoption`
591
+ * leaves it. The control poller is stopped because `activateContext` started
592
+ * one and will start another when the entry attaches for real.
593
+ *
594
+ * The adoption window is deliberately NOT re-armed: the replay to come
595
+ * carries the message's own text and attaches by the ordinary match, so
596
+ * re-arming would only widen the chance of adopting yet another turn.
597
+ */
598
+ function restoreAdoptedContext(ctx) {
599
+ // A context that has begun settling must never go back on the queue:
600
+ // `finalizeContext` marks `finalized` before its awaited publishes, and a
601
+ // finalized entry re-attached by a later turn returns from `finalizeContext`
602
+ // immediately — so `closeContext` never runs and `activeCtx` is pinned to a
603
+ // corpse for the life of the process (the 2026-08-18 deadlock shape).
604
+ if (ctx.finalized) return;
605
+ detachActiveContext(ctx);
606
+ ctx.adoptedFromCompaction = false;
607
+ ctx.provisionalSince = 0;
608
+ // `activateContext` started a control poller keyed on this row, so the
609
+ // provisional window is the one moment a still-QUEUED message is
610
+ // Stoppable. An abort taken there belongs to the turn that was running,
611
+ // not to the row: left set, `finalizeContext` would take its interrupted
612
+ // branch when the row finally runs for real and publish no response at all.
613
+ ctx.interrupted = false;
614
+ controlPoller?.stop?.(ctx.controlState);
615
+ ctx.controlState = null;
616
+ const entry = ctx.deliveredEntry;
617
+ if (!entry) return;
618
+ entry.unattachedSince = 0;
619
+ proc.pendingDelivered.unshift(entry);
620
+ }
621
+
622
+ /**
623
+ * The adopted turn turned out to belong to a settled task's continuation,
624
+ * which replayed its notification into the same turn after the compaction
625
+ * summary opened it. Give the row back and register the continuation the
626
+ * turn actually is.
627
+ */
628
+ function handBackAdoptedContext(ctx) {
629
+ restoreAdoptedContext(ctx);
630
+ dbg('post-compaction adoption handed back to a task continuation', ctx.message?.id || '(no id)');
631
+ return openContinuationContext();
632
+ }
633
+
634
+ /**
635
+ * The CLI started a turn on its own (a background task's notification).
636
+ * Register a synthetic relay turn for it; actions buffer until the server
637
+ * hands back a message id, then flush in order. A failed registration
638
+ * discards the turn's relay output (it still lands in the native
639
+ * transcript) rather than failing the process.
640
+ */
641
+ function openContinuationContext() {
642
+ const ctx = createContext('continuation', {
643
+ id: null,
644
+ conversationId: sdkSessionId,
645
+ relayMode: proc.relayMode,
646
+ model: '',
647
+ });
648
+ ctx.registered = false;
649
+ activateContext(ctx);
650
+ (async () => {
651
+ let response = null;
652
+ // Retry on any registration that produced no message id — a truthy but
653
+ // empty response body must not end the loop early.
654
+ for (let attempt = 0; attempt < 3 && !response?.messageId; attempt += 1) {
655
+ // Handed off / settled before a row was created: stop before making one.
656
+ if (ctx.finalized || ctx.discarded) return;
657
+ response = await api('POST', '/api/continuation-turn', {
658
+ conversationId: sdkSessionId,
659
+ sdkSessionId,
660
+ relayMode: ctx.message.relayMode,
661
+ trigger: 'background_task',
662
+ }).catch((error) => {
663
+ dbg('continuation turn registration failed', error?.message || String(error));
664
+ return null;
665
+ });
666
+ if (!response?.messageId) await new Promise((resolve) => setTimeout(resolve, continuationRetryDelayMs));
667
+ }
668
+ if (!response?.messageId) {
669
+ ctx.discarded = true;
670
+ ctx.bufferedActions = [];
671
+ controlPoller?.stop?.(ctx.controlState);
672
+ ctx.controlState = null;
673
+ dbg('continuation turn discarded (no relay message id)');
674
+ return;
675
+ }
676
+ if (ctx.discarded || ctx.finalized) {
677
+ // The context was handed off or settled while registration was in
678
+ // flight; drop the just-created server row (requeue fails a
679
+ // processing continuation quietly) instead of leaving it orphaned.
680
+ await api('POST', '/api/requeue', { messageId: String(response.messageId) }).catch(() => {});
681
+ controlPoller?.stop?.(ctx.controlState);
682
+ ctx.controlState = null;
683
+ dbg('continuation turn dropped (handed off during registration)');
684
+ return;
685
+ }
686
+ ctx.message.id = String(response.messageId);
687
+ // The route reports which conversation the synthetic row landed on;
688
+ // trusting it beats assuming worker session id === conversation id.
689
+ if (String(response.conversationId || '').trim()) {
690
+ ctx.message.conversationId = String(response.conversationId).trim();
691
+ }
692
+ // Restart the control poller now that the turn has its real queue id.
693
+ controlPoller?.stop?.(ctx.controlState);
694
+ ctx.controlState = controlPoller?.start?.({
695
+ queueMessageId: ctx.message.id,
696
+ onAbortTurn: () => interruptActiveTurn(ctx),
697
+ }) || null;
698
+ // Drain in arrival order; anything the consumer adds while a batch is in
699
+ // flight keeps buffering (registered is still false) so a later action
700
+ // can never overtake an earlier one.
701
+ while (ctx.bufferedActions.length) {
702
+ const batch = ctx.bufferedActions.splice(0);
703
+ for (const action of batch) {
704
+ await publisher.dispatchAction(ctx.message, action, ctx.state);
705
+ if (action.channel === 'result') await finalizeContext(ctx);
706
+ }
707
+ }
708
+ ctx.registered = true;
709
+ })().catch((error) => {
710
+ dbg('continuation flush failed', error?.message || String(error));
711
+ });
712
+ return ctx;
713
+ }
714
+
715
+ /** Wait until a continuation context has its relay queue row (or gave up). */
716
+ async function waitForContextRegistration(ctx, timeoutMs = 10_000) {
717
+ const deadline = Date.now() + timeoutMs;
718
+ while (!ctx.registered && !ctx.discarded && Date.now() < deadline) {
719
+ await new Promise((resolve) => setTimeout(resolve, 50));
720
+ }
721
+ }
722
+
723
+ /**
724
+ * Decide which turn context an SDK message belongs to, opening or attaching
725
+ * one when the message begins a turn. Delivered turns attach on their user
726
+ * replay (matched by text) or, when no replay precedes assistant traffic, on
727
+ * that traffic; anything the CLI starts by itself becomes a continuation.
728
+ */
729
+ function resolveContext(sdkMessage) {
730
+ if (proc.activeCtx) {
731
+ // The CLI dequeues a message pushed mid-turn INTO the running turn
732
+ // (steering) instead of opening a new turn after it: the replay arrives
733
+ // while another context is active, so the turn's single result would
734
+ // land on that context and the delivered row would stay `processing`
735
+ // forever while its lease renews (conv f93135ac row 962c36b1,
736
+ // 2026-08-18). Detect the absorbed replay and hand the turn off — the
737
+ // active context settles with what it already streamed, and everything
738
+ // from the replay on, including the result, belongs to the delivered
739
+ // message it answers.
740
+ if (proc.pendingDelivered.length) {
741
+ const signature = turnOpeningUserSignature(sdkMessage);
742
+ const absorbed = signature === null
743
+ ? -1
744
+ : proc.pendingDelivered.findIndex((entry) => entry.expectedText === signature);
745
+ if (absorbed >= 0) {
746
+ const outgoing = proc.activeCtx;
747
+ const restored = outgoing.adoptedFromCompaction;
748
+ if (restored) {
749
+ // The outgoing context was only PROVISIONALLY adopted and has
750
+ // published nothing. Settling it as an absorbed turn would tell
751
+ // the user their message was "merged into the next reply" and
752
+ // refuse to requeue it — but the CLI never consumed that prompt,
753
+ // so it is still owed a replay. Unwind the adoption instead; the
754
+ // row goes back to the queue and attaches for real later.
755
+ restoreAdoptedContext(outgoing);
756
+ } else {
757
+ // onSdkMessage awaits this before dispatching the new context's
758
+ // actions, so the handed-off row's publishes stay ordered ahead
759
+ // of the absorbed turn's.
760
+ proc.handoffSettling = handoffAbsorbedTurn(outgoing);
761
+ }
762
+ const ctx = attachDeliveredContext(
763
+ proc.pendingDelivered.findIndex((entry) => entry.expectedText === signature),
764
+ );
765
+ // Continuity: open thinking blocks and tool_use/tool_result pairing
766
+ // must survive the boundary — the stream did not restart. Only for a
767
+ // genuine hand-off, where the outgoing context is settled and never
768
+ // used again: a RESTORED one goes back on the queue and will run its
769
+ // own turn later, and a shared normalizer would then publish this
770
+ // turn's streamed text as that row's answer (its `streamText` is only
771
+ // reset by a `message_start`, so it outlives the result).
772
+ if (!restored) ctx.normalizer = outgoing.normalizer;
773
+ return ctx;
774
+ }
775
+ }
776
+ // The turn was adopted for a delivered message on the strength of an
777
+ // untagged post-compaction replay — and now a task notification replays
778
+ // into the same turn. The compaction happened INSIDE a continuation the
779
+ // CLI had already opened for a settled task, so the summary row was
780
+ // never the delivered message's: the turn belongs to the task. Hand it
781
+ // back before anything publishes (see handBackAdoptedContext).
782
+ if (proc.activeCtx.adoptedFromCompaction) {
783
+ const signature = turnOpeningUserSignature(sdkMessage);
784
+ // The adopted message's OWN replay: the CLI has now proved it dequeued
785
+ // exactly this prompt, so the guess is confirmed and the turn is the
786
+ // row's for good. Every unwind path rests on "the CLI never consumed
787
+ // this prompt" — after this replay that premise is false, and putting
788
+ // the row back would fail or re-run a message the CLI already took.
789
+ if (signature !== null && signature === proc.activeCtx.deliveredEntry?.expectedText) {
790
+ proc.activeCtx.adoptedFromCompaction = false;
791
+ return proc.activeCtx;
792
+ }
793
+ if (signature !== null && isTaskNotificationReplay(sdkMessage)) {
794
+ return handBackAdoptedContext(proc.activeCtx);
795
+ }
796
+ // A phantom result closes the turn with nothing published. Committed,
797
+ // it would leave the adopted context active forever — no result to
798
+ // finalize it, no queue entry left for the watchdog to fail over, and
799
+ // `handlePendingPayload` never resolving. Give the row back and let
800
+ // the ordinary phantom skip run.
801
+ if (isPhantomResult(sdkMessage)) {
802
+ restoreAdoptedContext(proc.activeCtx);
803
+ proc.lastBoundary = null;
804
+ return null;
805
+ }
806
+ // The adoption commits on the turn's first real OUTPUT, and only
807
+ // that. `system` frames publish nothing, and the compaction's own
808
+ // `compact_result` status routinely lands between the summary row and
809
+ // a notification replay. Nor does a background subagent's stream
810
+ // count: it arrives at top level carrying `parent_tool_use_id`, it is
811
+ // a LIVE task's chatter rather than this turn's answer, and a task
812
+ // running while the user's message is queued is the ordinary state of
813
+ // the conversation this bug came from — committing on it would leave
814
+ // the hand-back unreachable in exactly the order it exists for.
815
+ const type = String(sdkMessage?.type || '');
816
+ // A `result` counts too. It is not "output" in the streaming sense —
817
+ // it is the turn ENDING — but leaving the flag set through
818
+ // `finalizeContext`'s several awaited relay round-trips gives the
819
+ // lifecycle timer a window to reap a context that is already settling.
820
+ if (!sdkMessage?.parent_tool_use_id
821
+ && (type === 'assistant' || type === 'stream_event' || type === 'result')) {
822
+ proc.activeCtx.adoptedFromCompaction = false;
823
+ }
824
+ }
825
+ return proc.activeCtx;
826
+ }
827
+ const type = String(sdkMessage?.type || '');
828
+
829
+ const openingText = turnOpeningUserText(sdkMessage);
830
+ if (openingText !== null) {
831
+ const matched = proc.pendingDelivered.findIndex((entry) => entry.expectedText === openingText);
832
+ if (matched >= 0) return attachDeliveredContext(matched);
833
+ // A compaction just replayed the conversation: the CLI re-opens the turn
834
+ // with its own summary message ("This session is being continued…"),
835
+ // whose text matches no delivered entry, and then answers the delivered
836
+ // message on that turn. Without adoption the answer publishes as a
837
+ // synthetic continuation and the real row orphans until the watchdog
838
+ // fails it — the user loses the message on a turn that succeeded (conv
839
+ // 563e252e, 2026-08-20). The turn is identified directly rather than by
840
+ // bookkeeping about what the CLI might owe: a settled task's
841
+ // continuation announces itself through `origin.kind` (see
842
+ // isTaskNotificationReplay), and everything else inside the window is
843
+ // the compaction's own replay. Only the no-active-context path adopts (a
844
+ // compaction mid-turn must leave the running turn alone), and the window
845
+ // is one-shot — attachDeliveredContext clears it through
846
+ // activateContext, so one boundary can never adopt two turns.
847
+ //
848
+ // `lastBoundary === 'self-opened'` is the turn-level half of the same
849
+ // question: the CLI had already begun a turn of its own (a task
850
+ // continuation, or a resumed init) and merely had not produced traffic
851
+ // yet, so a compaction inside THAT turn emits its untagged summary row
852
+ // into a turn that was never the delivered message's. The tag says which
853
+ // MESSAGE this is; the boundary says which TURN it belongs to, and
854
+ // adoption needs both.
855
+ if (
856
+ proc.pendingDelivered.length
857
+ && proc.lastBoundary !== 'self-opened'
858
+ && !proc.continuationInitPending
859
+ && Date.now() < proc.compactReplayUntil
860
+ && isCompactionReplayCandidate(sdkMessage)
861
+ ) {
862
+ const ctx = attachDeliveredContext();
863
+ // Provisional: the summary row alone cannot rule out a continuation
864
+ // whose notification replays after it (see the hand-back above).
865
+ ctx.adoptedFromCompaction = true;
866
+ return ctx;
867
+ }
868
+ // A turn the CLI opened on its own (task-notification replay). The
869
+ // context opens lazily on its first real traffic so a bookkeeping
870
+ // replay that ends in a phantom result never creates a relay turn.
871
+ proc.lastBoundary = 'self-opened';
872
+ return null;
873
+ }
874
+
875
+ if (type === 'assistant' || type === 'stream_event' || type === 'user') {
876
+ if (proc.lastBoundary !== 'self-opened') {
877
+ if (proc.pendingDelivered.length) return attachDeliveredContext();
878
+ // Between-turn chatter — a background subagent's stream, a stray
879
+ // top-level tool_result — is not a turn of its own. Only traffic that
880
+ // follows a self-opened boundary (the CLI's task-notification replay)
881
+ // may open a continuation; without that boundary there is no top-level
882
+ // result coming to ever close the context, and an open context wedges
883
+ // the process. The dropped frames still land in the native transcript.
884
+ return null;
885
+ }
886
+ return openContinuationContext();
887
+ }
888
+
889
+ if (type === 'result') {
890
+ if (isPhantomResult(sdkMessage)) {
891
+ proc.lastBoundary = null;
892
+ return null;
893
+ }
894
+ if (proc.lastBoundary !== 'self-opened' && proc.pendingDelivered.length) return attachDeliveredContext();
895
+ if (proc.lastBoundary === 'self-opened') return openContinuationContext();
896
+ return null;
897
+ }
898
+
899
+ return null;
900
+ }
901
+
902
+ async function dispatchToContext(ctx, action) {
903
+ // Background-task membership is process state, observed from the raw
904
+ // stream; the normalizer's mirror actions must not reach the relay APIs.
905
+ if (action.channel === 'background_tasks' || action.channel === 'background_task_settled') return;
906
+ if (ctx.discarded) {
907
+ // The turn's relay output is deliberately dropped, but the turn still
908
+ // ends: its result must release the active slot, or every later message
909
+ // routes into this dead context and the process can never idle out.
910
+ if (action.channel === 'result') closeContext(ctx, false);
911
+ return;
912
+ }
913
+ if (!ctx.registered) {
914
+ ctx.bufferedActions.push(action);
915
+ return;
916
+ }
917
+ await publisher.dispatchAction(ctx.message, action, ctx.state);
918
+ if (action.channel === 'result') await finalizeContext(ctx);
919
+ }
920
+
921
+ async function finalizeContext(ctx) {
922
+ if (ctx.finalized) return;
923
+ ctx.finalized = true;
924
+ controlPoller?.stop?.(ctx.controlState);
925
+ // A late continuation flush can land after the process died; usage reads
926
+ // and model fallbacks must survive that.
927
+ const turnRef = proc?.turn || null;
928
+ const procModel = proc?.model || null;
929
+ // The control transport is alive for the process's whole life now, but
930
+ // the snapshot still belongs to this turn's finalize so the composer
931
+ // indicator updates with each reply.
932
+ const [contextUsage, planUsage] = await Promise.all([
933
+ readContextUsageImpl(turnRef, dbg),
934
+ readPlanUsageImpl(turnRef, dbg),
935
+ ]);
936
+ ctx.state.contextUsage = contextUsage;
937
+ ctx.state.planUsage = planUsage;
938
+ const responseModel = ctx.state.responseModel || procModel || null;
939
+ await publisher.publishContextUsage({ message: ctx.message, state: ctx.state, model: procModel, sdkSessionId });
940
+ await publisher.publishPlanUsage({ message: ctx.message, state: ctx.state, sdkSessionId });
941
+
942
+ if (ctx.interrupted || proc?.aborted) {
943
+ // Same shape as the per-turn runner's abort path: surface what streamed,
944
+ // let the server-side abort control own the queue row's fate.
945
+ await publisher.publishFinalStream(ctx.message, ctx.state.lastStreamedText);
946
+ } else if (ctx.state.result?.isError) {
947
+ await publisher.publishErrorResult({ message: ctx.message, state: ctx.state, responseModel });
948
+ } else {
949
+ await publisher.publishCompletedTurn({
950
+ message: ctx.message,
951
+ state: ctx.state,
952
+ responseModel,
953
+ planBoardPosted: ctx.planBoardPosted,
954
+ });
955
+ }
956
+ closeContext(ctx, true);
957
+ }
958
+
959
+ /** The SDK stream ended while this turn was still open (no result seen). */
960
+ async function finalizeContextOnStreamEnd(ctx, { aborted = false, model = null } = {}) {
961
+ if (ctx.finalized) return;
962
+ ctx.finalized = true;
963
+ controlPoller?.stop?.(ctx.controlState);
964
+ if (ctx.discarded) {
965
+ closeContext(ctx, true);
966
+ return;
967
+ }
968
+ // The transport is gone, so nothing new can be read — but usage already
969
+ // captured on this context (a result seen before the stream died) still
970
+ // reaches the relay. Best-effort by definition of this path.
971
+ if (ctx.state.contextUsage || ctx.state.planUsage) {
972
+ await publisher.publishContextUsage({ message: ctx.message, state: ctx.state, model, sdkSessionId }).catch(() => {});
973
+ await publisher.publishPlanUsage({ message: ctx.message, state: ctx.state, sdkSessionId }).catch(() => {});
974
+ }
975
+ if (ctx.interrupted || aborted) {
976
+ await publisher.publishFinalStream(ctx.message, ctx.state.lastStreamedText);
977
+ closeContext(ctx, true);
978
+ return;
979
+ }
980
+ const fallbackText = String(ctx.state.lastStreamedText || ctx.normalizer.finalStreamText() || '').trim();
981
+ if (fallbackText) {
982
+ await publisher.publishFinalStream(ctx.message, fallbackText);
983
+ await publisher.publishResponse(ctx.message, {
984
+ text: fallbackText,
985
+ model: ctx.state.responseModel || model || null,
986
+ });
987
+ } else if (ctx.message?.id) {
988
+ await api('POST', '/api/requeue', { messageId: ctx.message.id }).catch(() => {});
989
+ }
990
+ closeContext(ctx, true);
991
+ }
992
+
993
+ function detachActiveContext(ctx) {
994
+ if (proc && proc.activeCtx === ctx) {
995
+ proc.activeCtx = null;
996
+ proc.lastBoundary = null;
997
+ }
998
+ }
999
+
1000
+ function closeContext(ctx, handled) {
1001
+ detachActiveContext(ctx);
1002
+ ctx.resolveDone?.(handled);
1003
+ evaluateLifecycle();
1004
+ }
1005
+
1006
+ /**
1007
+ * The active turn absorbed a delivered message (steering): settle the
1008
+ * active context NOW. The caller attaches the delivered context
1009
+ * synchronously; onSdkMessage awaits the returned promise before
1010
+ * dispatching the new context's actions so the two rows' publishes stay
1011
+ * ordered.
1012
+ */
1013
+ function handoffAbsorbedTurn(ctx) {
1014
+ detachActiveContext(ctx);
1015
+ if (ctx.kind === 'continuation' && !ctx.registered) {
1016
+ // Registration (or its buffered-action drain) is still in flight:
1017
+ // discard, and empty the buffer so the drain loop halts instead of
1018
+ // writing to a row that is about to be dropped. The registration path
1019
+ // drops the server row itself when the id arrives after this.
1020
+ ctx.discarded = true;
1021
+ ctx.bufferedActions = [];
1022
+ }
1023
+ // Captured synchronously: the incoming context inherits this normalizer,
1024
+ // so reading it later would see the absorbed turn's text too.
1025
+ const fallbackText = String(ctx.state.lastStreamedText || ctx.normalizer.finalStreamText() || '').trim();
1026
+ dbg('active turn absorbed a delivered message; handing off', ctx.message?.id || '(unregistered continuation)');
1027
+ return settleHandedOffContext(ctx, fallbackText).catch((error) => {
1028
+ dbg('absorbed-turn handoff settle failed', error?.message || String(error));
1029
+ });
1030
+ }
1031
+
1032
+ async function settleHandedOffContext(ctx, fallbackText) {
1033
+ if (ctx.finalized) return;
1034
+ ctx.finalized = true;
1035
+ controlPoller?.stop?.(ctx.controlState);
1036
+ if (ctx.discarded) {
1037
+ // A discarded continuation that already has its server row must still
1038
+ // drop it (requeue fails a processing continuation quietly).
1039
+ if (ctx.message?.id) {
1040
+ await api('POST', '/api/requeue', { messageId: ctx.message.id }).catch(() => {});
1041
+ }
1042
+ closeContext(ctx, true);
1043
+ return;
1044
+ }
1045
+ const model = ctx.state.responseModel || proc?.model || null;
1046
+ if (ctx.kind === 'delivered') {
1047
+ // NEVER requeue an absorbed delivered row: the CLI already consumed
1048
+ // the prompt into the running turn — re-delivery would execute it a
1049
+ // second time (and the requeue route marks the healthy worker errored).
1050
+ const text = fallbackText
1051
+ ? `${fallbackText}\n\n_(This turn was merged into the next message — the reply continues there.)_`
1052
+ : '_(This turn was merged into the next message — see the following reply.)_';
1053
+ await publisher.publishFinalStream(ctx.message, text);
1054
+ await publisher.publishResponse(ctx.message, { text, model });
1055
+ } else if (fallbackText) {
1056
+ await publisher.publishFinalStream(ctx.message, fallbackText);
1057
+ await publisher.publishResponse(ctx.message, { text: fallbackText, model });
1058
+ } else if (ctx.message?.id) {
1059
+ await api('POST', '/api/requeue', { messageId: ctx.message.id }).catch(() => {});
1060
+ }
1061
+ closeContext(ctx, true);
1062
+ }
1063
+
1064
+ async function interruptActiveTurn(ctx) {
1065
+ ctx.interrupted = true;
1066
+ try {
1067
+ await proc.turn.interrupt();
1068
+ } catch (error) {
1069
+ // No interrupt support (or a dead transport): fall back to killing the
1070
+ // process, which is exactly the pre-persistent abort behavior.
1071
+ dbg('interrupt failed, aborting process', error?.message || String(error));
1072
+ hardAbortProcess();
1073
+ }
1074
+ }
1075
+
1076
+ function hardAbortProcess() {
1077
+ if (!proc) return;
1078
+ proc.aborted = true;
1079
+ proc.abortController.abort();
1080
+ }
1081
+
1082
+ // ---------------------------------------------------------------------------
1083
+ // Process-level stream observation
1084
+
1085
+ /**
1086
+ * Ship the live task set (enriched with per-task progress) to the relay,
1087
+ * which renders it as the composer's background-tasks panel. Membership
1088
+ * changes post immediately; chatty progress updates are trailing-edge
1089
+ * throttled. Advisory: failures must never disturb the stream consumer.
1090
+ */
1091
+ function publishBackgroundTasks({ throttled = false } = {}) {
1092
+ const processRef = proc;
1093
+ if (!processRef) return;
1094
+ const send = () => {
1095
+ processRef.taskPublishTimer = null;
1096
+ const tasks = [...processRef.liveTasks.entries()].map(([taskId, task]) => {
1097
+ // The SDK's task events never carry a model (verified against
1098
+ // 0.3.226): a pinned one lives on the spawning tool_use block
1099
+ // (subagentSpawns, keyed by the task's tool_use_id); without a pin an
1100
+ // agent-like task runs on the session model. Resolved at publish
1101
+ // time so the spawn block landing after task_started still heals on
1102
+ // the next publish. Bash & co. get no model at all.
1103
+ const agentLike = AGENT_TASK_TYPES.has(String(task.taskType || ''));
1104
+ const pinnedModel = (task.toolUseId && processRef.subagentSpawns.get(task.toolUseId)) || '';
1105
+ const model = agentLike ? (pinnedModel || processRef.initModel || null) : null;
1106
+ return {
1107
+ taskId,
1108
+ taskType: task.taskType,
1109
+ description: task.description,
1110
+ startedAt: task.startedAt || null,
1111
+ summary: task.summary || null,
1112
+ lastToolName: task.lastToolName || null,
1113
+ totalTokens: task.totalTokens ?? null,
1114
+ toolUseId: task.toolUseId || null,
1115
+ subagentType: task.subagentType || null,
1116
+ model,
1117
+ modelInherited: Boolean(model && !pinnedModel),
1118
+ // The workflow digest (poller below) rides the same row; the relay
1119
+ // sanitizer whitelists and re-clamps it before storing.
1120
+ ...(task.workflowProgress ? { workflowProgress: task.workflowProgress } : {}),
1121
+ };
1122
+ });
1123
+ api('POST', '/api/background-tasks', { conversationId: sdkSessionId, tasks }).catch((error) => {
1124
+ dbg('background task publish failed', error?.message || String(error));
1125
+ });
1126
+ };
1127
+ if (!throttled) {
1128
+ if (processRef.taskPublishTimer) {
1129
+ clearTimeout(processRef.taskPublishTimer);
1130
+ processRef.taskPublishTimer = null;
1131
+ }
1132
+ send();
1133
+ return;
1134
+ }
1135
+ if (processRef.taskPublishTimer) return;
1136
+ processRef.taskPublishTimer = setTimeout(send, 2_000);
1137
+ processRef.taskPublishTimer.unref?.();
1138
+ }
1139
+
1140
+ // ---------------------------------------------------------------------------
1141
+ // Workflow progress poller
1142
+ //
1143
+ // A `local_workflow` task is ONE opaque row on the task-events path; its
1144
+ // per-agent tree lives on disk (see the module-level readers above). While
1145
+ // such a task is live, an interval polls the tree, digests it, and attaches
1146
+ // the digest to the task's published row as `workflowProgress` — publishing
1147
+ // only when the digest actually changed. Same lifecycle discipline as
1148
+ // taskPublishTimer: unref'd, cleared on settle and in cleanupProcess.
1149
+
1150
+ function workflowSessionDirFor(processRef) {
1151
+ const nativeSessionId = String(processRef.nativeSessionId || claudeNativeSessionId || '').trim();
1152
+ if (!nativeSessionId) return '';
1153
+ try {
1154
+ return String(resolveWorkflowSessionDir({ nativeSessionId, cwd }) || '');
1155
+ } catch {
1156
+ return '';
1157
+ }
1158
+ }
1159
+
1160
+ function getWorkflowState(processRef, taskId) {
1161
+ let state = processRef.workflowStates.get(taskId);
1162
+ if (!state) {
1163
+ state = { runId: '', labels: new Map(), digestJson: '' };
1164
+ processRef.workflowStates.set(taskId, state);
1165
+ }
1166
+ return state;
1167
+ }
1168
+
1169
+ /**
1170
+ * The freshest digest for one workflow task, or null. The run record wins
1171
+ * whenever it exists (it is written at completion and carries the full
1172
+ * tree); until then the live journal is the source. `recordOnly` is the
1173
+ * settle-time final read, where a journal re-read could only regress.
1174
+ */
1175
+ function buildWorkflowDigest(processRef, sessionDir, taskId, task, { recordOnly = false } = {}) {
1176
+ const state = getWorkflowState(processRef, taskId);
1177
+ const startedAt = Number(task.startedAt) || 0;
1178
+
1179
+ if (state.runId) {
1180
+ const record = readWorkflowRunRecord(sessionDir, state.runId);
1181
+ if (record) {
1182
+ const recordTaskId = String(record.taskId || '').trim();
1183
+ if (recordTaskId && recordTaskId !== taskId) {
1184
+ // The mtime heuristic bound this task to another task's run; the
1185
+ // record's taskId is authoritative, so drop the binding and let the
1186
+ // taskId scan re-match on a later poll.
1187
+ state.runId = '';
1188
+ } else {
1189
+ const digest = digestFromRunRecord(record);
1190
+ if (digest) return digest;
1191
+ }
1192
+ }
1193
+ }
1194
+ if (!state.runId) {
1195
+ const record = scanWorkflowRunRecords({ sessionDir, taskId, startedAt });
1196
+ const digest = record ? digestFromRunRecord(record) : null;
1197
+ if (digest) {
1198
+ state.runId = digest.runId;
1199
+ return digest;
1200
+ }
1201
+ }
1202
+ if (recordOnly) return null;
1203
+
1204
+ if (!state.runId) state.runId = findLiveWorkflowRunId({ sessionDir, startedAt });
1205
+ if (!state.runId) return null;
1206
+ const runDir = nodePath.join(sessionDir, 'subagents', 'workflows', state.runId);
1207
+ const entries = readWorkflowJournal(runDir);
1208
+ if (!entries) return null;
1209
+ for (const entry of entries) {
1210
+ const agentId = String(entry?.agentId || '').trim();
1211
+ if (!agentId || state.labels.has(agentId)) continue;
1212
+ // Cache label hits only: a miss may just be a first line still being
1213
+ // flushed, and re-reading 8KB per poll until it lands is cheap.
1214
+ const label = readWorkflowAgentLabel(runDir, agentId);
1215
+ if (label) state.labels.set(agentId, label);
1216
+ }
1217
+ return digestFromJournal({
1218
+ entries,
1219
+ labelsByAgentId: state.labels,
1220
+ workflowName: null,
1221
+ runId: state.runId,
1222
+ });
1223
+ }
1224
+
1225
+ function stopWorkflowPoller(processRef) {
1226
+ if (processRef.workflowPollTimer) {
1227
+ clearInterval(processRef.workflowPollTimer);
1228
+ processRef.workflowPollTimer = null;
1229
+ }
1230
+ }
1231
+
1232
+ function pollWorkflowProgress(processRef) {
1233
+ if (processRef !== proc || processRef.closing) {
1234
+ stopWorkflowPoller(processRef);
1235
+ return;
1236
+ }
1237
+ const workflowTasks = [...processRef.liveTasks.entries()]
1238
+ .filter(([, task]) => String(task.taskType || '') === 'local_workflow');
1239
+ if (!workflowTasks.length) {
1240
+ stopWorkflowPoller(processRef);
1241
+ return;
1242
+ }
1243
+ const sessionDir = workflowSessionDirFor(processRef);
1244
+ if (!sessionDir) return;
1245
+ let changed = false;
1246
+ for (const [taskId, task] of workflowTasks) {
1247
+ let digest = null;
1248
+ try {
1249
+ digest = buildWorkflowDigest(processRef, sessionDir, taskId, task);
1250
+ } catch {
1251
+ // Advisory by contract: a digest failure must never disturb the turn.
1252
+ }
1253
+ if (!digest) continue;
1254
+ const digestJson = JSON.stringify(digest);
1255
+ const state = getWorkflowState(processRef, taskId);
1256
+ if (state.digestJson === digestJson) continue;
1257
+ state.digestJson = digestJson;
1258
+ task.workflowProgress = digest;
1259
+ changed = true;
1260
+ }
1261
+ if (changed) publishBackgroundTasks();
1262
+ }
1263
+
1264
+ /** Start/stop the poller to match the live task set; prune settled state. */
1265
+ function syncWorkflowPoller(processRef) {
1266
+ for (const taskId of [...processRef.workflowStates.keys()]) {
1267
+ if (!processRef.liveTasks.has(taskId)) processRef.workflowStates.delete(taskId);
1268
+ }
1269
+ const hasWorkflowTask = [...processRef.liveTasks.values()]
1270
+ .some((task) => String(task.taskType || '') === 'local_workflow');
1271
+ if (!hasWorkflowTask) {
1272
+ stopWorkflowPoller(processRef);
1273
+ return;
1274
+ }
1275
+ if (processRef.workflowPollTimer || processRef.closing) return;
1276
+ processRef.workflowPollTimer = setInterval(() => pollWorkflowProgress(processRef), workflowPollMs);
1277
+ processRef.workflowPollTimer.unref?.();
1278
+ }
1279
+
1280
+ // How many settled workflows can wait for their summarizing response at
1281
+ // once — matches the relay-side cap on `workflowRuns` per message.
1282
+ const MAX_SETTLED_WORKFLOW_RUNS = 5;
1283
+
1284
+ /** True for any status that names an outcome (not in-flight, not absent). */
1285
+ function isSettledWorkflowStatus(value) {
1286
+ const status = String(value || '').trim().toLowerCase();
1287
+ return Boolean(status) && status !== 'running';
1288
+ }
1289
+
1290
+ /**
1291
+ * Remember a settled workflow's final digest for the next /api/response
1292
+ * publish (the "Finished background task" card on the summarizing turn).
1293
+ * The run record's digest is authoritative; a run that never wrote one
1294
+ * (stopped early) settles with its last live digest, whose stale 'running'
1295
+ * status is replaced by the settle notification's ('stopped', 'failed').
1296
+ * Both settle signals may call this for the same task in either order —
1297
+ * a terminal status, once seen, is never downgraded.
1298
+ */
1299
+ function bufferSettledWorkflowRun(taskId, task, notificationStatus = '') {
1300
+ if (!proc || !task || String(task.taskType || '') !== 'local_workflow') return;
1301
+ try {
1302
+ const sessionDir = workflowSessionDirFor(proc);
1303
+ const recordDigest = sessionDir
1304
+ ? buildWorkflowDigest(proc, sessionDir, taskId, task, { recordOnly: true })
1305
+ : null;
1306
+ const digest = recordDigest
1307
+ || (task.workflowProgress && typeof task.workflowProgress === 'object' ? task.workflowProgress : null);
1308
+ if (!digest) return;
1309
+ const previous = proc.settledWorkflowDigests.get(taskId) || null;
1310
+ const status = [digest.status, notificationStatus, previous?.digest?.status]
1311
+ .find(isSettledWorkflowStatus) || digest.status || null;
1312
+ proc.settledWorkflowDigests.delete(taskId);
1313
+ proc.settledWorkflowDigests.set(taskId, {
1314
+ digest: { ...digest, status },
1315
+ // A journal-snapshot digest gets one more record-read attempt at
1316
+ // drain time (the CLI can flush the run record moments after the
1317
+ // settle signals); startedAt is what the taskId scan needs then.
1318
+ fromRecord: Boolean(recordDigest),
1319
+ startedAt: Number(task.startedAt) || 0,
1320
+ });
1321
+ while (proc.settledWorkflowDigests.size > MAX_SETTLED_WORKFLOW_RUNS) {
1322
+ proc.settledWorkflowDigests.delete(proc.settledWorkflowDigests.keys().next().value);
1323
+ }
1324
+ } catch {
1325
+ // Advisory by contract: buffering must never disturb the settle path.
1326
+ }
1327
+ }
1328
+
1329
+ /**
1330
+ * Removal-first settle order: the digest was already buffered when the task
1331
+ * left the live set, but only the late task_notification knows how it ended.
1332
+ */
1333
+ function refineSettledWorkflowStatus(taskId, notificationStatus) {
1334
+ const status = String(notificationStatus || '').trim();
1335
+ const buffered = proc?.settledWorkflowDigests.get(taskId);
1336
+ if (!buffered || !status) return;
1337
+ if (!isSettledWorkflowStatus(buffered.digest.status)) buffered.digest.status = status;
1338
+ }
1339
+
1340
+ /**
1341
+ * Hand the buffered digests to the response publish and clear the buffer.
1342
+ * Entries that settled on a journal snapshot (record not on disk yet — the
1343
+ * CLI flushes it moments after the settle signals, observed live as a card
1344
+ * frozen with a "running" verify agent) retry the authoritative record read
1345
+ * here: the summarizing response publishes seconds later, when the record
1346
+ * exists.
1347
+ */
1348
+ function drainSettledWorkflowRuns() {
1349
+ if (!proc || !proc.settledWorkflowDigests.size) return null;
1350
+ const entries = [...proc.settledWorkflowDigests.entries()];
1351
+ proc.settledWorkflowDigests.clear();
1352
+ const sessionDir = workflowSessionDirFor(proc);
1353
+ return entries.map(([taskId, entry]) => {
1354
+ if (!entry.fromRecord && sessionDir) {
1355
+ try {
1356
+ const record = buildWorkflowDigest(
1357
+ proc,
1358
+ sessionDir,
1359
+ taskId,
1360
+ { startedAt: entry.startedAt },
1361
+ { recordOnly: true },
1362
+ );
1363
+ if (record) {
1364
+ return isSettledWorkflowStatus(record.status)
1365
+ ? record
1366
+ : { ...record, status: entry.digest.status };
1367
+ }
1368
+ } catch {
1369
+ // Advisory: the snapshot digest below is still a valid card.
1370
+ }
1371
+ }
1372
+ return entry.digest;
1373
+ });
1374
+ }
1375
+
1376
+ /**
1377
+ * Settle-time final read: the run record lands at completion, so the
1378
+ * task_notification is the one moment the completed tree (final states,
1379
+ * tokens, logs) can still ride the row before it clears.
1380
+ */
1381
+ function publishFinalWorkflowDigest(taskId, notificationStatus = '') {
1382
+ const task = proc?.liveTasks.get(taskId);
1383
+ if (!task || String(task.taskType || '') !== 'local_workflow') {
1384
+ refineSettledWorkflowStatus(taskId, notificationStatus);
1385
+ return;
1386
+ }
1387
+ bufferSettledWorkflowRun(taskId, task, notificationStatus);
1388
+ try {
1389
+ const sessionDir = workflowSessionDirFor(proc);
1390
+ if (!sessionDir) return;
1391
+ const digest = buildWorkflowDigest(proc, sessionDir, taskId, task, { recordOnly: true });
1392
+ if (!digest) return;
1393
+ const digestJson = JSON.stringify(digest);
1394
+ const state = getWorkflowState(proc, taskId);
1395
+ if (state.digestJson === digestJson) return;
1396
+ state.digestJson = digestJson;
1397
+ task.workflowProgress = digest;
1398
+ publishBackgroundTasks();
1399
+ } catch {
1400
+ // Advisory: a failed final read leaves the last live digest standing.
1401
+ }
1402
+ }
1403
+
1404
+ // Remember the model each subagent spawn pinned explicitly: only the
1405
+ // spawning Agent/Task tool_use block carries it (input.model), and
1406
+ // task_started.tool_use_id links that block to its task. Capped so a
1407
+ // long-lived process holding many spawns can't grow the map unbounded.
1408
+ function recordSubagentSpawns(sdkMessage) {
1409
+ const blocks = Array.isArray(sdkMessage?.message?.content) ? sdkMessage.message.content : [];
1410
+ for (const block of blocks) {
1411
+ if (block?.type !== 'tool_use' || !isSubagentToolName(block.name)) continue;
1412
+ const toolUseId = String(block.id || '').trim();
1413
+ const model = String(block.input?.model || '').trim();
1414
+ if (!toolUseId || !model) continue;
1415
+ proc.subagentSpawns.set(toolUseId, model);
1416
+ while (proc.subagentSpawns.size > 100) {
1417
+ proc.subagentSpawns.delete(proc.subagentSpawns.keys().next().value);
1418
+ }
1419
+ }
1420
+ }
1421
+
1422
+ // Between-turn notices carried into the next activated context, as either a
1423
+ // plain line or a prepared publisher action (a compaction boundary has to
1424
+ // keep the structured metadata the transcript's break row is built from —
1425
+ // as prose it would render as an ordinary tool-activity line). Capped —
1426
+ // api_retry made this the only accumulator a retry storm could grow
1427
+ // unboundedly (subagentSpawns and settledWorkflowDigests already cap).
1428
+ const MAX_PENDING_ACTIVITIES = 50;
1429
+ function carryPendingActivity(entry) {
1430
+ const carried = entry && typeof entry === 'object'
1431
+ ? entry
1432
+ : String(entry || '').trim().slice(0, 2000);
1433
+ if (!carried) return;
1434
+ proc.pendingActivities.push(carried);
1435
+ // Drop the OLDEST PROSE line first, and a structured entry only when
1436
+ // nothing else is left: the cap exists to bound a retry storm's chatter,
1437
+ // while a structured entry carries transcript geometry no later line can
1438
+ // restore (a compaction boundary buffered between turns, then 50 task
1439
+ // notifications, used to lose its break row silently). The client's
1440
+ // capRelayActivityEntries protects structured rows too, but head-caps the
1441
+ // prose it keeps; here the newest lines are the informative ones.
1442
+ while (proc.pendingActivities.length > MAX_PENDING_ACTIVITIES) {
1443
+ const proseIndex = proc.pendingActivities.findIndex((carried) => typeof carried === 'string');
1444
+ proc.pendingActivities.splice(proseIndex === -1 ? 0 : proseIndex, 1);
1445
+ }
1446
+ }
1447
+
1448
+ // ---------------------------------------------------------------------------
1449
+ // Compaction
1450
+ //
1451
+ // Compaction is a long, completely silent stretch of CLI work — 133 s on a
1452
+ // 614k-token session resumed against a 100k window (conv 563e252e,
1453
+ // 2026-08-20) — that produces no turn context of its own. Two things must
1454
+ // survive it: the process (nothing else is "live" while it runs) and the
1455
+ // delivered message that triggered it (the CLI answers it AFTER the
1456
+ // boundary, on a stream the runner would otherwise read as self-opened).
1457
+
1458
+ function isCompacting() {
1459
+ return Boolean(proc?.compactingSince) && Date.now() - proc.compactingSince < compactionStaleMs;
1460
+ }
1461
+
1462
+ /**
1463
+ * Whether a between-turn notice can reach the relay through the active
1464
+ * context, or has to be buffered for the next one. A discarded context is
1465
+ * active but publishes nothing (dispatchToContext drops it), so it counts as
1466
+ * no context at all.
1467
+ */
1468
+ function activeContextPublishes() {
1469
+ return Boolean(proc.activeCtx) && !proc.activeCtx.discarded;
1470
+ }
1471
+
1472
+ /**
1473
+ * The CLI is compacting, or just did: a delivered turn is still coming.
1474
+ * Armed only while a delivered entry is actually waiting; which turn the
1475
+ * window may then be spent on is decided when that turn opens, by reading
1476
+ * the message itself (isCompactionReplayCandidate).
1477
+ */
1478
+ function armCompactReplayAdoption() {
1479
+ if (!proc.pendingDelivered.length) return;
1480
+ proc.compactReplayUntil = Date.now() + compactReplayAdoptionMs;
1481
+ // Whatever the entries waited through, they were not idle: the CLI is
1482
+ // about to replay the turn they belong to.
1483
+ for (const entry of proc.pendingDelivered) entry.unattachedSince = 0;
1484
+ }
1485
+
1486
+ function observeProcessLevel(sdkMessage) {
1487
+ const type = String(sdkMessage?.type || '');
1488
+ if (type === 'assistant') {
1489
+ recordSubagentSpawns(sdkMessage);
1490
+ return;
1491
+ }
1492
+ if (type !== 'system') return;
1493
+ const subtype = String(sdkMessage.subtype || '');
1494
+ if (subtype === 'init') {
1495
+ // The FIRST init of a process is the spawn's, and a spawn is always
1496
+ // driven by a delivered message; a LATER one is the CLI opening a turn
1497
+ // by itself.
1498
+ const firstInit = !proc.sawInit;
1499
+ proc.sawInit = true;
1500
+ persistNativeSessionId(sdkSessionId, sdkMessage.session_id).catch(() => {});
1501
+ // Tracked on the process too (not only through the persist round-trip):
1502
+ // the workflow poller needs the native id to find the session dir even
1503
+ // when the persist call is still in flight or failed.
1504
+ proc.nativeSessionId = String(sdkMessage.session_id || '').trim() || proc.nativeSessionId;
1505
+ // Init always lands before any context is active, so the per-context
1506
+ // normalizer never sees it — capture the CLI's actual model here and
1507
+ // seed it into every context at activation. Without this, a composer
1508
+ // set to `auto` published responses with model: null.
1509
+ proc.initModel = String(sdkMessage.model || '').trim() || proc.initModel;
1510
+ // A fresh init with no active turn AND nothing queued to deliver is the CLI
1511
+ // opening a turn on its own — a background-task continuation ("you will be
1512
+ // notified"). The real SDK does NOT emit a user-message replay for these
1513
+ // (verified against the live SDK: the settle is background_tasks_changed +
1514
+ // task_notification, then a bare init, then assistant/result), so without
1515
+ // marking the boundary here resolveContext() drops the continuation's
1516
+ // assistant frames as between-turn chatter and nothing publishes. A
1517
+ // delivered turn always has its row in pendingDelivered before its init is
1518
+ // processed (spawnProcess + pendingDelivered.push run synchronously ahead
1519
+ // of the async stream consumer), so it is correctly excluded here.
1520
+ //
1521
+ if (!proc.activeCtx && !proc.pendingDelivered.length) {
1522
+ proc.lastBoundary = 'self-opened';
1523
+ } else if (!proc.activeCtx && !firstInit) {
1524
+ // A LATER init, with a delivered row waiting, is the CLI opening a turn
1525
+ // of its own — the live-verified continuation shape is a bare init with
1526
+ // no user replay at all. A delivered turn cannot produce one: the
1527
+ // process is already running, and only a spawn inits (which is
1528
+ // `firstInit`, and always has its row queued before the stream is
1529
+ // read). A compaction inside such a turn would otherwise see no
1530
+ // turn-level boundary and adopt the queued row — and in this shape
1531
+ // nothing later replays to correct it.
1532
+ //
1533
+ // Structural, not a clock. Gating this on "a task settled recently"
1534
+ // fails twice over: the grace (60 s) is shorter than a compaction
1535
+ // (133 s measured), and `activateContext` zeroes the settle timestamps,
1536
+ // so any turn attaching in between erases the signal for good. Every
1537
+ // freshness-clock design in this fix's history failed the same way.
1538
+ //
1539
+ // Deliberately NOT `lastBoundary`: that flag also diverts assistant
1540
+ // traffic away from a waiting delivered row, and a settle timestamp is
1541
+ // far too weak to justify that (it stays fresh for the whole
1542
+ // notification grace after ANY task settles, compaction or not, and
1543
+ // would fail the row over while publishing its answer on a synthetic
1544
+ // continuation). This suppresses adoption and nothing else; if the turn
1545
+ // really is the delivered message's, it attaches exactly as before.
1546
+ proc.continuationInitPending = true;
1547
+ }
1548
+ return;
1549
+ }
1550
+ if (subtype === 'status') {
1551
+ // The status line is the compaction hold's only in-progress signal, so
1552
+ // every spelling that is NOT a permission-mode notice releases it:
1553
+ // `{status:null, compact_result}` is the normal terminator, a BARE
1554
+ // `{status:null}` is what an early-compact-start that produced no
1555
+ // compaction emits (no result, no boundary — the hold would otherwise
1556
+ // stand until the staleness cap, delaying the watchdog and blocking the
1557
+ // mode-change recycle), and 'requesting' comes from the main loop's
1558
+ // stream_request_start, which by definition means the CLI is past
1559
+ // compacting (the compaction fork reports progress as `stream_mode`,
1560
+ // which the SDK drops). `{status:null, permissionMode}` is a mode-change
1561
+ // notice that says nothing about compaction and is ignored.
1562
+ if (String(sdkMessage.status || '') === 'compacting') {
1563
+ proc.compactingSince = Date.now();
1564
+ armCompactReplayAdoption();
1565
+ } else if (sdkMessage.permissionMode === undefined) {
1566
+ proc.compactingSince = 0;
1567
+ // A compaction that failed produces no boundary and no replay: the
1568
+ // turn just carries on uncompacted and attaches the normal way, so
1569
+ // leaving the adoption window open would only widen the chance of a
1570
+ // later self-opened turn being adopted.
1571
+ if (String(sdkMessage.compact_result || '') === 'failed' || sdkMessage.compact_error !== undefined) {
1572
+ proc.compactReplayUntil = 0;
1573
+ }
1574
+ }
1575
+ evaluateLifecycle();
1576
+ return;
1577
+ }
1578
+ if (subtype === 'compact_boundary') {
1579
+ proc.compactingSince = 0;
1580
+ armCompactReplayAdoption();
1581
+ // The boundary is a per-turn normalizer action, but a compaction at
1582
+ // resume — exactly what lowering the window causes — lands with no
1583
+ // context to publish onto and would be dropped. Buffer it for the next
1584
+ // one; with a turn active the normalizer publishes it (resolveContext
1585
+ // routes system messages to the active context and nowhere else), so
1586
+ // the two paths can never both fire. An already-discarded context is
1587
+ // routed here too: it is still `activeCtx`, but dispatchToContext drops
1588
+ // everything it is handed. That does not cover every way a boundary can
1589
+ // still be lost — a context discarded AFTER buffering it (a continuation
1590
+ // whose registration fails all three attempts) drops the whole turn's
1591
+ // output, boundary included — but those paths lose the turn itself, so
1592
+ // the break row is the least of what is missing.
1593
+ if (!activeContextPublishes()) {
1594
+ carryPendingActivity(compactBoundaryActivityAction(sdkMessage));
1595
+ }
1596
+ return;
1597
+ }
1598
+ if (subtype === 'model_refusal_fallback') {
1599
+ // The CLI retried a safeguards-refused request on a fallback model
1600
+ // (Fable 5 → Opus 4.8 in conv 3366b9d3). Track it at process level:
1601
+ // later contexts must seed the model that is actually running, and a
1602
+ // session-scoped switch must be undone on the next delivered turn when
1603
+ // the composer pinned a model (adaptProcess repins via setModel). An
1604
+ // `auto` composer keeps the CLI's own behavior — only the recorded
1605
+ // model follows the switch.
1606
+ const fallbackModel = String(sdkMessage.fallbackModel || '').trim();
1607
+ if (fallbackModel) {
1608
+ proc.initModel = fallbackModel;
1609
+ if (String(sdkMessage.scope || '').trim().toLowerCase() === 'session') {
1610
+ proc.modelFallback = fallbackModel;
1611
+ }
1612
+ }
1613
+ // With a turn active the normalizer surfaces the CLI's notice (and the
1614
+ // attribution update); between turns the notice carries to the next one.
1615
+ if (!proc.activeCtx) {
1616
+ const notice = String(sdkMessage.content || '').trim()
1617
+ || `Model switched to ${fallbackModel || 'a fallback model'} after a refusal.`;
1618
+ carryPendingActivity(notice);
1619
+ }
1620
+ return;
1621
+ }
1622
+ if (subtype === 'api_retry') {
1623
+ // With a turn active the normalizer surfaces this as an activity on
1624
+ // that turn. Otherwise: a delivered row waiting on this very request
1625
+ // gets the notice NOW — buffering it until attach would publish the
1626
+ // explanation only after the stall is over (silent 529 retries read
1627
+ // as relay freezes on 2026-08-18). With no addressable row, carry it,
1628
+ // collapsing consecutive retry lines — only the latest matters.
1629
+ if (!proc.activeCtx) {
1630
+ const notice = formatApiRetryNotice(sdkMessage);
1631
+ const waiting = proc.pendingDelivered[0]?.ctx.message;
1632
+ if (waiting?.id) {
1633
+ publisher.postActivity(waiting, notice);
1634
+ } else {
1635
+ const last = proc.pendingActivities[proc.pendingActivities.length - 1];
1636
+ if (typeof last === 'string' && last.startsWith('Anthropic API')) proc.pendingActivities.pop();
1637
+ carryPendingActivity(notice);
1638
+ }
1639
+ }
1640
+ return;
1641
+ }
1642
+ if (subtype === 'background_tasks_changed') {
1643
+ const tasks = Array.isArray(sdkMessage.tasks) ? sdkMessage.tasks : [];
1644
+ const previous = proc.liveTasks;
1645
+ proc.liveTasks = new Map(tasks
1646
+ .filter((task) => String(task?.task_id || '').trim())
1647
+ .map((task) => {
1648
+ const taskId = String(task.task_id).trim();
1649
+ const known = previous.get(taskId) || {};
1650
+ return [taskId, {
1651
+ ...known,
1652
+ taskType: String(task?.task_type || '').trim() || known.taskType || '',
1653
+ description: String(task?.description || '').trim() || known.description || '',
1654
+ startedAt: known.startedAt || Date.now(),
1655
+ }];
1656
+ }));
1657
+ for (const taskId of proc.liveTasks.keys()) {
1658
+ // A task started after an expiry deserves its own timeout budget —
1659
+ // the one-shot latch would otherwise never cap it.
1660
+ if (!previous.has(taskId)) {
1661
+ proc.tasksExpired = false;
1662
+ proc.tasksExpiredAt = 0;
1663
+ proc.heldForTasksSince = 0;
1664
+ }
1665
+ proc.knownTasks.add(taskId);
1666
+ }
1667
+ // A task the process knew about just left the live set: it settled, and
1668
+ // the CLI is likely about to dequeue its continuation turn ("you will be
1669
+ // notified"). Pin the process for the grace window so it can't idle out in
1670
+ // the gap before that continuation's first traffic arrives. This is the
1671
+ // reliable "continuation imminent" signal and does not depend on the
1672
+ // settling task_notification (which may be silent — skip_transcript — or
1673
+ // arrive after this message); the notification-based pin is secondary.
1674
+ for (const taskId of previous.keys()) {
1675
+ if (!proc.liveTasks.has(taskId)) {
1676
+ proc.taskSettledAt = Date.now();
1677
+ break;
1678
+ }
1679
+ }
1680
+ // The live SDK usually drops a settled task's row BEFORE its
1681
+ // task_notification arrives, so this removal is the reliable moment to
1682
+ // capture a settling workflow's final digest for the transcript card
1683
+ // (the notification then refines the status when it lands).
1684
+ for (const [taskId, task] of previous) {
1685
+ if (!proc.liveTasks.has(taskId)) bufferSettledWorkflowRun(taskId, task);
1686
+ }
1687
+ syncWorkflowPoller(proc);
1688
+ publishBackgroundTasks();
1689
+ evaluateLifecycle();
1690
+ return;
1691
+ }
1692
+ if (subtype === 'task_started') {
1693
+ const taskId = String(sdkMessage.task_id || '').trim();
1694
+ if (!taskId) return;
1695
+ const known = proc.liveTasks.get(taskId);
1696
+ if (known) {
1697
+ known.description = String(sdkMessage.description || '').trim() || known.description;
1698
+ known.taskType = String(sdkMessage.task_type || '').trim() || known.taskType;
1699
+ known.subagentType = String(sdkMessage.subagent_type || '').trim() || known.subagentType || '';
1700
+ // The task's spawning tool_use id is the same identity the subagent
1701
+ // bubbles key on — recorded so the panel and the bubbles can be
1702
+ // correlated (and a targeted stop can find its task).
1703
+ known.toolUseId = String(sdkMessage.tool_use_id || '').trim() || known.toolUseId || '';
1704
+ // task_started can be the first event carrying the task_type; a
1705
+ // workflow revealed here must start its poller too.
1706
+ syncWorkflowPoller(proc);
1707
+ publishBackgroundTasks({ throttled: true });
1708
+ }
1709
+ return;
1710
+ }
1711
+ if (subtype === 'task_progress') {
1712
+ const taskId = String(sdkMessage.task_id || '').trim();
1713
+ const known = taskId ? proc.liveTasks.get(taskId) : null;
1714
+ if (known) {
1715
+ known.summary = String(sdkMessage.summary || '').trim() || known.summary;
1716
+ known.lastToolName = String(sdkMessage.last_tool_name || '').trim() || known.lastToolName;
1717
+ known.subagentType = String(sdkMessage.subagent_type || '').trim() || known.subagentType || '';
1718
+ known.toolUseId = String(sdkMessage.tool_use_id || '').trim() || known.toolUseId || '';
1719
+ const totalTokens = Number(sdkMessage.usage?.total_tokens);
1720
+ if (Number.isFinite(totalTokens)) known.totalTokens = totalTokens;
1721
+ publishBackgroundTasks({ throttled: true });
1722
+ }
1723
+ return;
1724
+ }
1725
+ if (subtype === 'task_notification') {
1726
+ const taskId = String(sdkMessage.task_id || '').trim();
1727
+ // A settling workflow gets one final run-record read so the completed
1728
+ // tree publishes before the row clears (best-effort: the CLI may have
1729
+ // already removed the task via background_tasks_changed).
1730
+ if (taskId) publishFinalWorkflowDigest(taskId, String(sdkMessage.status || '').trim());
1731
+ // A settled session-level task means the CLI is about to dequeue a
1732
+ // continuation turn — the process must not idle out under it. The SDK
1733
+ // flags silent notifications explicitly: skip_transcript means no
1734
+ // continuation is coming, so nothing should pin the process for one.
1735
+ if (taskId && proc.knownTasks.has(taskId) && sdkMessage.skip_transcript !== true) {
1736
+ proc.notificationPendingAt = Date.now();
1737
+ }
1738
+ if (!activeContextPublishes()) {
1739
+ const status = String(sdkMessage.status || '').trim() || 'unknown';
1740
+ const summary = String(sdkMessage.summary || '').trim();
1741
+ carryPendingActivity(`Background task ${taskId || 'unknown'} ${status}: ${summary}`);
1742
+ }
1743
+ return;
1744
+ }
1745
+ if (subtype === 'session_state_changed') {
1746
+ evaluateLifecycle();
1747
+ }
1748
+ }
1749
+
1750
+ async function onSdkMessage(processRef, sdkMessage) {
1751
+ // A superseded process (push-race respawn, mode-recycle timeout) may still
1752
+ // be draining its stream; its late messages must not mutate the state of
1753
+ // the process that replaced it. Its own open contexts are settled by
1754
+ // cleanupProcess when the old stream ends.
1755
+ if (!proc || proc !== processRef) return;
1756
+ proc.lastEventAt = Date.now();
1757
+ observeProcessLevel(sdkMessage);
1758
+ const ctx = resolveContext(sdkMessage);
1759
+ if (proc.handoffSettling) {
1760
+ // An absorbed-steering handoff just settled the previous context;
1761
+ // publishes for the incoming context must not overtake it.
1762
+ const settling = proc.handoffSettling;
1763
+ proc.handoffSettling = null;
1764
+ await settling;
1765
+ }
1766
+ if (!ctx) return;
1767
+ const actions = ctx.normalizer.normalize(sdkMessage);
1768
+ for (const action of actions) {
1769
+ await dispatchToContext(ctx, action);
1770
+ }
1771
+ }
1772
+
1773
+ // ---------------------------------------------------------------------------
1774
+ // Process lifecycle
1775
+
1776
+ /**
1777
+ * A background task settled and the CLI is about to dequeue a turn of its
1778
+ * own ("you will be notified"). Both signals are used: the task leaving the
1779
+ * live set (reliable, see background_tasks_changed) and the settling
1780
+ * task_notification (secondary — it may be silent or late).
1781
+ */
1782
+ function isContinuationImminent() {
1783
+ if (!proc) return false;
1784
+ const fresh = (at) => Boolean(at) && Date.now() - at < notificationGraceMs;
1785
+ return fresh(proc.notificationPendingAt) || fresh(proc.taskSettledAt);
1786
+ }
1787
+
1788
+ function hasLiveWork() {
1789
+ if (!proc) return false;
1790
+ return Boolean(
1791
+ proc.activeCtx
1792
+ || proc.pendingDelivered.length
1793
+ || proc.liveTasks.size
1794
+ || isContinuationImminent()
1795
+ // A compaction produces no stream traffic for minutes; an idle shutdown
1796
+ // under it would kill the CLI mid-flight.
1797
+ || isCompacting()
1798
+ || proc.pendingControlRequests > 0,
1799
+ );
1800
+ }
1801
+
1802
+ /**
1803
+ * Watchdog: a delivered entry whose replay never attached — the CLI
1804
+ * absorbed the message into a turn that already ended, or dropped it —
1805
+ * would renew its queue row's lease forever while blocking every later
1806
+ * message (the 2026-08-18 deadlock's failure mode). With no active turn,
1807
+ * no live tasks, no control round-trip in flight, and a quiet stream,
1808
+ * nothing can attach it anymore: reject its ctx.done so
1809
+ * handlePendingPayload's catch publishes a terminal error and the row
1810
+ * fails over instead of wedging the conversation.
1811
+ */
1812
+ /**
1813
+ * Adoption splices a delivered entry out of `pendingDelivered` on an
1814
+ * inference, which also takes it out of the watchdog's reach: if the turn
1815
+ * the CLI opened then produces nothing at all (a retry storm, a dropped
1816
+ * turn), `activeCtx` pins the process, nothing can fail the row over, and
1817
+ * the queue row renews its lease forever — the 2026-08-18 deadlock shape.
1818
+ * The three ways an adoption unwinds all need the CLI to say *something*,
1819
+ * so silence gets its own: a provisional adoption that never produces
1820
+ * output puts the row back, and the ordinary watchdog takes it from there.
1821
+ */
1822
+ function reapSilentProvisionalAdoption(now) {
1823
+ const ctx = proc?.activeCtx;
1824
+ if (!ctx?.adoptedFromCompaction) return;
1825
+ if (!(pendingDeliveredTimeoutMs > 0)) return;
1826
+ if (isCompacting() || proc.liveTasks.size || proc.pendingControlRequests > 0) {
1827
+ ctx.provisionalSince = now;
1828
+ return;
1829
+ }
1830
+ if (!ctx.provisionalSince) {
1831
+ ctx.provisionalSince = now;
1832
+ return;
1833
+ }
1834
+ if (now - ctx.provisionalSince < pendingDeliveredTimeoutMs) return;
1835
+ dbg('provisional adoption produced nothing; returning the row to the queue', ctx.message?.id || '(no id)');
1836
+ restoreAdoptedContext(ctx);
1837
+ }
1838
+
1839
+ function reapStalePendingDelivered(now) {
1840
+ if (!proc || !proc.pendingDelivered.length) return;
1841
+ if (!(pendingDeliveredTimeoutMs > 0)) return; // 0 = watchdog disabled
1842
+ // A silent cold boot (no init yet) is slow, not wedged: a CLI that never
1843
+ // inits dies on its own and cleanupProcess rejects the contexts then.
1844
+ if (!proc.sawInit) return;
1845
+ if (proc.activeCtx || proc.liveTasks.size || proc.pendingControlRequests > 0 || isCompacting()) {
1846
+ // A turn, task, control round-trip, or compaction may still attach these
1847
+ // entries — the unattached clock starts over once the process actually
1848
+ // goes quiet. A compaction is silent work, not idleness: the CLI replays
1849
+ // the turn these entries belong to once it finishes.
1850
+ for (const entry of proc.pendingDelivered) entry.unattachedSince = 0;
1851
+ return;
1852
+ }
1853
+ const stale = [];
1854
+ proc.pendingDelivered = proc.pendingDelivered.filter((entry) => {
1855
+ if (!entry.unattachedSince) {
1856
+ entry.unattachedSince = now;
1857
+ return true;
1858
+ }
1859
+ if (now - entry.unattachedSince < pendingDeliveredTimeoutMs) return true;
1860
+ stale.push(entry);
1861
+ return false;
1862
+ });
1863
+ for (const entry of stale) {
1864
+ dbg('pending delivered watchdog fired', entry.ctx.message?.id || '(no id)');
1865
+ entry.ctx.finalized = true;
1866
+ entry.ctx.rejectDone?.(new Error(
1867
+ `claude worker watchdog: the CLI never opened a turn for this message within ${Math.round(pendingDeliveredTimeoutMs / 1000)}s of going idle (absorbed or dropped); the row is failed — resend the message to retry`,
1868
+ ));
1869
+ }
1870
+ }
1871
+
1872
+ function evaluateLifecycle() {
1873
+ if (!proc || proc.closing) return;
1874
+ const now = Date.now();
1875
+ reapSilentProvisionalAdoption(now);
1876
+ reapStalePendingDelivered(now);
1877
+ if (hasLiveWork()) {
1878
+ // Track how long background tasks alone have held the process, for the
1879
+ // user-configurable timeout (0 = unlimited).
1880
+ const heldByTasksOnly = !proc.activeCtx && !proc.pendingDelivered.length && proc.liveTasks.size > 0;
1881
+ if (heldByTasksOnly) {
1882
+ if (!proc.heldForTasksSince) proc.heldForTasksSince = now;
1883
+ const timeoutMs = Number(getBackgroundTaskTimeoutMs()) || 0;
1884
+ if (timeoutMs > 0 && now - proc.heldForTasksSince >= timeoutMs) {
1885
+ expireBackgroundTasks(timeoutMs).catch(() => {});
1886
+ }
1887
+ // Hard backstop: stopTask only *requests* the stops. If the CLI
1888
+ // still reports the tasks live well past the expiry, drop them from
1889
+ // the local set so the hold releases and the normal idle wind-down
1890
+ // (endInput → CLI exit) enforces the timeout for real.
1891
+ if (proc.tasksExpired && proc.tasksExpiredAt && now - proc.tasksExpiredAt >= 60_000) {
1892
+ dbg('background tasks still live after expiry; forcing the hold release', [...proc.liveTasks.keys()].join(','));
1893
+ proc.liveTasks = new Map();
1894
+ publishBackgroundTasks();
1895
+ }
1896
+ } else {
1897
+ proc.heldForTasksSince = 0;
1898
+ }
1899
+ return;
1900
+ }
1901
+ proc.heldForTasksSince = 0;
1902
+ if (now - proc.lastEventAt >= idleShutdownMs) {
1903
+ dbg('claude session process idling out', sdkSessionId.slice(0, 8));
1904
+ gracefulShutdown('idle');
1905
+ }
1906
+ }
1907
+
1908
+ async function expireBackgroundTasks(timeoutMs) {
1909
+ if (!proc || proc.tasksExpired) return;
1910
+ proc.tasksExpired = true;
1911
+ proc.tasksExpiredAt = Date.now();
1912
+ dbg('background task timeout reached', `${Math.round(timeoutMs / 60000)}min`, [...proc.liveTasks.keys()].join(','));
1913
+ for (const taskId of proc.liveTasks.keys()) {
1914
+ // Each stop emits a task_notification (status 'stopped') — the CLI's own
1915
+ // continuation turn is what tells the user, through the normal path.
1916
+ await Promise.resolve(proc.turn.stopTask?.(taskId)).catch((error) => {
1917
+ dbg('stopTask failed', taskId, error?.message || String(error));
1918
+ });
1919
+ }
1920
+ }
1921
+
1922
+ function gracefulShutdown(reason) {
1923
+ if (!proc || proc.closing) return;
1924
+ proc.closing = true;
1925
+ dbg('releasing claude session process', reason);
1926
+ try {
1927
+ proc.turn.endInput?.();
1928
+ } catch {}
1929
+ }
1930
+
1931
+ function stopLifecycleTimer(processRef) {
1932
+ if (processRef.lifecycleTimer) {
1933
+ clearInterval(processRef.lifecycleTimer);
1934
+ processRef.lifecycleTimer = null;
1935
+ }
1936
+ }
1937
+
1938
+ async function cleanupProcess(processRef, streamError) {
1939
+ const wasCurrent = proc === processRef;
1940
+ stopLifecycleTimer(processRef);
1941
+ if (processRef.taskPublishTimer) {
1942
+ clearTimeout(processRef.taskPublishTimer);
1943
+ processRef.taskPublishTimer = null;
1944
+ }
1945
+ stopWorkflowPoller(processRef);
1946
+ // The process took its background tasks with it; clear the panel — but
1947
+ // only when this process still owns it. A superseded process clearing the
1948
+ // panel would blank the replacement's live task set.
1949
+ if (processRef.liveTasks.size) {
1950
+ processRef.liveTasks = new Map();
1951
+ if (wasCurrent) {
1952
+ api('POST', '/api/background-tasks', { conversationId: sdkSessionId, tasks: [] }).catch(() => {});
1953
+ }
1954
+ }
1955
+ const openContexts = [
1956
+ ...(processRef.activeCtx ? [processRef.activeCtx] : []),
1957
+ ...processRef.pendingDelivered.map((entry) => entry.ctx),
1958
+ ];
1959
+ processRef.pendingDelivered = [];
1960
+ processRef.activeCtx = null;
1961
+ if (proc === processRef) proc = null;
1962
+ for (const ctx of openContexts) {
1963
+ if (ctx.finalized) continue;
1964
+ if (streamError && !processRef.aborted && !ctx.interrupted) {
1965
+ controlPoller?.stop?.(ctx.controlState);
1966
+ ctx.finalized = true;
1967
+ if (ctx.kind === 'delivered') {
1968
+ // handlePendingPayload's catch publishes the turn-failed response,
1969
+ // preserving the per-turn runner's error contract.
1970
+ ctx.rejectDone?.(streamError);
1971
+ } else if (ctx.registered && ctx.message?.id) {
1972
+ await publisher.publishTurnException({
1973
+ message: ctx.message,
1974
+ errorText: String(streamError?.message || streamError || 'unknown error'),
1975
+ }).catch(() => {});
1976
+ }
1977
+ continue;
1978
+ }
1979
+ await finalizeContextOnStreamEnd(ctx, {
1980
+ aborted: processRef.aborted,
1981
+ model: processRef.model,
1982
+ }).catch(() => {});
1983
+ }
1984
+ }
1985
+
1986
+ // ---------------------------------------------------------------------------
1987
+ // Spawn / adapt
1988
+
1989
+ function spawnProcess(message) {
1990
+ const relayMode = message.relayMode || 'agent';
1991
+ const model = resolvePerTurnModel(message);
1992
+ const effort = normalizeClaudeEffort(message.reasoningEffort);
1993
+ const autoCompactWindow = normalizeAutoCompactWindow(getAutoCompactWindow());
1994
+ const thinkingState = getThinking() || {};
1995
+ const thinkingEnabled = parseThinkingEnabled(thinkingState.enabled);
1996
+ const thinkingDisplay = parseThinkingDisplay(thinkingState.display);
1997
+ const abortController = new AbortController();
1998
+ const processRef = {
1999
+ turn: null,
2000
+ abortController,
2001
+ model,
2002
+ relayMode,
2003
+ effort,
2004
+ autoCompactWindow,
2005
+ // Seeded from what the process is SPAWNED with, so the adapt diff is
2006
+ // against reality rather than against a delivered setting the CLI may
2007
+ // never have honored.
2008
+ thinkingEnabled,
2009
+ thinkingDisplay,
2010
+ // No budget pin exists today, but `setMaxThinkingTokens`' first
2011
+ // argument is positional and re-sent on every display change — thread
2012
+ // the tracked value so a future budget feature cannot be silently
2013
+ // cleared by a display toggle.
2014
+ thinkingBudget: null,
2015
+ appendClass: modeAppendClass(relayMode),
2016
+ permissionMode: permissionModeForRelayMode(relayMode),
2017
+ liveTasks: new Map(),
2018
+ knownTasks: new Set(),
2019
+ // taskId → { runId, labels, digestJson } for live local_workflow tasks;
2020
+ // pruned by syncWorkflowPoller when a task leaves the live set.
2021
+ workflowStates: new Map(),
2022
+ // taskId → final digest of a workflow that settled, waiting to ride the
2023
+ // next /api/response publish as `workflowRuns` (the transcript's
2024
+ // "Finished background task" card). Capped at 5, drained on attach; if
2025
+ // the process dies before a response publishes, these are lost (v1).
2026
+ settledWorkflowDigests: new Map(),
2027
+ // The freshest native session id the process itself has seen (seeded
2028
+ // from `resume`, updated on init) — the workflow poller's key into
2029
+ // `~/.claude/projects`.
2030
+ nativeSessionId: '',
2031
+ // tool_use id → explicitly pinned model of a subagent spawn block; the
2032
+ // task panel's model column resolves through this at publish time.
2033
+ subagentSpawns: new Map(),
2034
+ initModel: '',
2035
+ // Set when the CLI reported a session-scoped refusal fallback; the next
2036
+ // delivered turn repins the composer's model instead of trusting
2037
+ // `model !== proc.model` (the relay's request never changed — the CLI
2038
+ // drifted underneath it).
2039
+ modelFallback: '',
2040
+ notificationPendingAt: 0,
2041
+ // When the CLI last said it was compacting (0 = not) and how long a
2042
+ // delivered entry may still adopt the turn the CLI re-opens afterwards.
2043
+ compactingSince: 0,
2044
+ compactReplayUntil: 0,
2045
+ // A non-spawn init that opened a turn while a delivered row waited: the
2046
+ // turn is the CLI's own, so no compaction inside it may adopt that row.
2047
+ continuationInitPending: false,
2048
+ pendingActivities: [],
2049
+ sawInit: false,
2050
+ handoffSettling: null,
2051
+ activeCtx: null,
2052
+ pendingDelivered: [],
2053
+ lastBoundary: null,
2054
+ pendingControlRequests: 0,
2055
+ lastEventAt: Date.now(),
2056
+ heldForTasksSince: 0,
2057
+ taskSettledAt: 0,
2058
+ tasksExpired: false,
2059
+ tasksExpiredAt: 0,
2060
+ aborted: false,
2061
+ closing: false,
2062
+ lifecycleTimer: null,
2063
+ taskPublishTimer: null,
2064
+ workflowPollTimer: null,
2065
+ consumer: null,
2066
+ };
2067
+
2068
+ const askUserBridge = createAskUserBridge({
2069
+ api,
2070
+ sdkSessionId,
2071
+ getActiveMessage: () => processRef.activeCtx?.message || processRef.pendingDelivered[0]?.ctx.message || null,
2072
+ dbg,
2073
+ ...askUserBridgeOptions,
2074
+ });
2075
+ const baseCanUseTool = createCanUseTool({
2076
+ askUserBridge,
2077
+ dbg,
2078
+ onExitPlanMode: async (input) => {
2079
+ const ctx = processRef.activeCtx || processRef.pendingDelivered[0]?.ctx || null;
2080
+ if (!ctx?.message?.id) return false;
2081
+ const posted = await publisher.publishPlanBoard(ctx.message, input);
2082
+ if (posted) ctx.planBoardPosted = true;
2083
+ return posted;
2084
+ },
2085
+ });
2086
+ const canUseTool = async (toolName, input, options) => {
2087
+ // In-flight canUseTool round-trips (AskUserQuestion, permission prompts)
2088
+ // pin the process: a pending question produces no stream traffic while
2089
+ // the human thinks, and an idle shutdown under it would reject the
2090
+ // request with "Tool permission stream closed".
2091
+ processRef.pendingControlRequests += 1;
2092
+ try {
2093
+ // A question from a background agent between turns has no active turn
2094
+ // to attach to, and /api/relay-question requires a processing queue
2095
+ // row — without one the bridge 409s and the question is silently
2096
+ // denied. Register a continuation turn first: it is born processing,
2097
+ // gives the card a real queue row, and the flow's eventual top-level
2098
+ // result (or stream end) closes it like any other continuation.
2099
+ if (
2100
+ toolName === 'AskUserQuestion'
2101
+ && proc === processRef
2102
+ && !processRef.activeCtx
2103
+ && !processRef.pendingDelivered.length
2104
+ ) {
2105
+ const ctx = openContinuationContext();
2106
+ await waitForContextRegistration(ctx);
2107
+ }
2108
+ return await baseCanUseTool(toolName, input, options);
2109
+ } finally {
2110
+ processRef.pendingControlRequests -= 1;
2111
+ processRef.lastEventAt = Date.now();
2112
+ }
2113
+ };
2114
+
2115
+ const resume = String(message.claudeNativeSessionId || claudeNativeSessionId || '').trim();
2116
+ processRef.nativeSessionId = resume;
2117
+ // The CLI resolves `resume` inside the project directory for *this* CWD, so
2118
+ // a session whose workspace root changed has to bring its transcript along
2119
+ // or every turn from here on fails with "No conversation found".
2120
+ if (resume) relocateTranscriptImpl({ nativeSessionId: resume, cwd, dbg });
2121
+
2122
+ processRef.turn = startClaudeSessionImpl({
2123
+ content: null,
2124
+ cwd,
2125
+ model,
2126
+ resume,
2127
+ relayMode,
2128
+ reasoningEffort: effort,
2129
+ autoCompactWindow,
2130
+ thinkingEnabled,
2131
+ thinkingDisplay,
2132
+ abortController,
2133
+ canUseTool,
2134
+ pathToClaudeCodeExecutable,
2135
+ api,
2136
+ // Resolved per tool call: one CLI process serves many turns, and a
2137
+ // continuation's row can land on a different conversation than the
2138
+ // worker's session id.
2139
+ getConversationId: () => String(
2140
+ processRef.activeCtx?.message?.conversationId
2141
+ || processRef.pendingDelivered[0]?.ctx.message?.conversationId
2142
+ || sdkSessionId
2143
+ || '',
2144
+ ),
2145
+ dbg,
2146
+ });
2147
+ processRef.lifecycleTimer = setInterval(() => evaluateLifecycle(), lifecyclePollMs);
2148
+ processRef.lifecycleTimer.unref?.();
2149
+ processRef.consumer = (async () => {
2150
+ let streamError = null;
2151
+ try {
2152
+ for await (const sdkMessage of processRef.turn) {
2153
+ await onSdkMessage(processRef, sdkMessage);
2154
+ }
2155
+ } catch (error) {
2156
+ if (!processRef.aborted && !abortController.signal.aborted) streamError = error;
2157
+ } finally {
2158
+ try {
2159
+ processRef.turn.endInput?.();
2160
+ } catch {}
2161
+ await cleanupProcess(processRef, streamError);
2162
+ }
2163
+ })();
2164
+ proc = processRef;
2165
+ return processRef;
2166
+ }
2167
+
2168
+ async function adaptProcess(message) {
2169
+ const relayMode = message.relayMode || 'agent';
2170
+ const model = resolvePerTurnModel(message);
2171
+ const effort = normalizeClaudeEffort(message.reasoningEffort);
2172
+
2173
+ // A mode change that would alter the spawn-time system prompt append gets
2174
+ // a fresh process — but only when nothing lives in the old one.
2175
+ if (modeAppendClass(relayMode) !== proc.appendClass && !hasLiveWork() && !proc.liveTasks.size) {
2176
+ const previous = proc;
2177
+ gracefulShutdown('mode-change');
2178
+ await Promise.race([
2179
+ previous.consumer,
2180
+ new Promise((resolve) => setTimeout(resolve, 10_000)),
2181
+ ]);
2182
+ if (proc === previous) {
2183
+ // The drain timed out: force the old CLI down so it cannot keep
2184
+ // streaming next to the replacement the caller is about to spawn.
2185
+ try { previous.turn.close?.(); } catch {}
2186
+ proc = null;
2187
+ }
2188
+ return null;
2189
+ }
2190
+
2191
+ const permissionMode = permissionModeForRelayMode(relayMode);
2192
+ if (permissionMode !== proc.permissionMode) {
2193
+ await Promise.resolve(proc.turn.setPermissionMode?.(permissionMode)).catch((error) => {
2194
+ dbg('setPermissionMode failed', error?.message || String(error));
2195
+ });
2196
+ proc.permissionMode = permissionMode;
2197
+ }
2198
+ // A falsy model/effort is a real setting too: switching the composer back
2199
+ // to `auto` (model '') or effort `none` must reset the live process to the
2200
+ // SDK default — setModel(undefined) and effortLevel null exist for exactly
2201
+ // that. The old truthiness gate left the previous pin in place forever.
2202
+ // A session-scoped refusal fallback moved the CLI off the pinned model
2203
+ // without the relay's request ever changing, so `model !== proc.model`
2204
+ // alone can never see the drift — a pinned (non-auto) model is re-asserted
2205
+ // on the next delivered turn. The flagged request itself stays on the
2206
+ // fallback (that retry already happened, by API policy); this only stops
2207
+ // the whole session from silently staying there. An `auto` composer
2208
+ // (model '') keeps whatever the CLI chose.
2209
+ const repinAfterFallback = Boolean(proc.modelFallback) && Boolean(model);
2210
+ if (model !== proc.model || repinAfterFallback) {
2211
+ await Promise.resolve(proc.turn.setModel?.(model || undefined)).catch((error) => {
2212
+ dbg('setModel failed', error?.message || String(error));
2213
+ });
2214
+ proc.model = model;
2215
+ if (model) proc.modelFallback = '';
2216
+ }
2217
+ if (effort !== proc.effort) {
2218
+ // 'ultracode' is not an effortLevel the CLI accepts — it maps to the
2219
+ // session-scoped ultracode/enableWorkflows flags (with the effort pinned
2220
+ // to the xhigh the flag implies); every other change clears those flags
2221
+ // alongside the new effortLevel.
2222
+ await Promise.resolve(proc.turn.applyFlagSettings?.(claudeUltracodeFlagSettings(effort))).catch((error) => {
2223
+ dbg('applyFlagSettings effort failed', error?.message || String(error));
2224
+ });
2225
+ proc.effort = effort;
2226
+ }
2227
+ // The window is a delivery-scoped setting, not a per-message one: it only
2228
+ // reaches a live process through the flag layer, and `null` (Auto) has to
2229
+ // be pushed too or clearing the slider would never take effect.
2230
+ const autoCompactWindow = normalizeAutoCompactWindow(getAutoCompactWindow());
2231
+ if (autoCompactWindow !== proc.autoCompactWindow) {
2232
+ await Promise.resolve(proc.turn.applyFlagSettings?.(claudeAutoCompactFlagSettings(autoCompactWindow))).catch((error) => {
2233
+ dbg('applyFlagSettings autoCompactWindow failed', error?.message || String(error));
2234
+ });
2235
+ proc.autoCompactWindow = autoCompactWindow;
2236
+ }
2237
+ // Thinking is NOT a symmetric copy of the window block: the probe
2238
+ // (2026-08-26, docs/plans/claude-thinking-control.md) showed the two
2239
+ // directions of the on/off axis are not equally reachable mid-session.
2240
+ const thinkingState = getThinking() || {};
2241
+ const thinkingEnabled = parseThinkingEnabled(thinkingState.enabled);
2242
+ const thinkingDisplay = parseThinkingDisplay(thinkingState.display);
2243
+ let reassertDisplay = false;
2244
+ if (thinkingEnabled !== proc.thinkingEnabled) {
2245
+ if (thinkingEnabled === true) {
2246
+ // Enabling applies live. The display MUST be re-sent afterwards, in
2247
+ // this order: a session spawned with thinking off carries no display
2248
+ // mode, so enabling without it yields visible-but-empty thinking.
2249
+ await Promise.resolve(proc.turn.applyFlagSettings?.(claudeThinkingFlagSettings(true))).catch((error) => {
2250
+ dbg('applyFlagSettings thinkingEnabled failed', error?.message || String(error));
2251
+ });
2252
+ proc.thinkingEnabled = true;
2253
+ reassertDisplay = true;
2254
+ } else {
2255
+ // Disabling: the CLI accepts the flag
2256
+ // call and silently ignores it (probe 2026-08-26), so both sending it
2257
+ // and recording it as applied would be lies — the second would desync
2258
+ // `proc.thinkingEnabled` from reality and make the next enable a
2259
+ // no-op diff. Nothing needs to be remembered here: the request is not
2260
+ // lost, because `spawnProcess` re-reads `getThinking()` and the next
2261
+ // process is born with it. Deliberately no bookkeeping field — an
2262
+ // earlier revision carried one that nothing ever read.
2263
+ }
2264
+ }
2265
+ if (thinkingDisplay !== proc.thinkingDisplay || reassertDisplay) {
2266
+ await applyThinkingDisplay(proc.turn, thinkingDisplay, { budget: proc.thinkingBudget, dbg });
2267
+ proc.thinkingDisplay = thinkingDisplay;
2268
+ }
2269
+ proc.relayMode = relayMode;
2270
+ return proc;
2271
+ }
2272
+
2273
+ // ---------------------------------------------------------------------------
2274
+ // Relay contract
2275
+
2276
+ async function handlePendingPayload(pending) {
2277
+ const message = pending?.message || null;
2278
+ if (!message) return false;
2279
+ try {
2280
+ if (proc && !proc.closing && !proc.aborted) {
2281
+ await adaptProcess(message);
2282
+ }
2283
+ if (!proc || proc.closing || proc.aborted) {
2284
+ // A closing process drains on its own; wait so two CLIs never share
2285
+ // the native session transcript.
2286
+ const previous = proc;
2287
+ if (previous) {
2288
+ await Promise.race([
2289
+ previous.consumer,
2290
+ new Promise((resolve) => setTimeout(resolve, 15_000)),
2291
+ ]);
2292
+ if (proc === previous) {
2293
+ try { previous.turn.close?.(); } catch {}
2294
+ proc = null;
2295
+ }
2296
+ }
2297
+ spawnProcess(message);
2298
+ // A cold start is the one moment a turn can sit silent for many
2299
+ // seconds (transcript load + first request); say so instead of
2300
+ // showing bare dots. Posted once here — not in spawnProcess — so a
2301
+ // push-race respawn cannot duplicate the line.
2302
+ publisher.postActivity(message, (message.claudeNativeSessionId || claudeNativeSessionId)
2303
+ ? 'Starting the Claude CLI (cold start — resuming the session transcript)…'
2304
+ : 'Starting the Claude CLI (cold start)…');
2305
+ }
2306
+ const content = buildClaudeUserContent(message);
2307
+ for (let attempt = 0; attempt < 2; attempt += 1) {
2308
+ const procRef = proc;
2309
+ const ctx = createDeliveredContext(message);
2310
+ procRef.pendingDelivered.push({ ctx, expectedText: contentText(content), pushedAt: Date.now() });
2311
+ try {
2312
+ procRef.turn.pushUserMessage(content);
2313
+ } catch (pushError) {
2314
+ // The stream ended under us (process wound down between the
2315
+ // liveness check and the push): retire this context and respawn.
2316
+ procRef.pendingDelivered = procRef.pendingDelivered.filter((entry) => entry.ctx !== ctx);
2317
+ dbg('push raced process teardown', pushError?.message || String(pushError));
2318
+ if (proc === procRef) proc = null;
2319
+ // Belt and braces: the stream was already ending, but make sure the
2320
+ // superseded CLI cannot linger next to its replacement.
2321
+ try { procRef.turn.close?.(); } catch {}
2322
+ spawnProcess(message);
2323
+ continue;
2324
+ }
2325
+ return await ctx.done;
2326
+ }
2327
+ throw new Error('claude session process closed while accepting the message');
2328
+ } catch (error) {
2329
+ const errorText = String(error?.message || error || 'unknown error');
2330
+ dbg('claude turn failed', message.id, errorText);
2331
+ await publisher.publishTurnException({ message, errorText });
2332
+ return true;
2333
+ }
2334
+ }
2335
+
2336
+ async function shutdown({ graceful = true } = {}) {
2337
+ const processRef = proc;
2338
+ if (!processRef) return;
2339
+ if (graceful && !hasLiveWork()) {
2340
+ gracefulShutdown('worker-shutdown');
2341
+ await Promise.race([
2342
+ processRef.consumer,
2343
+ new Promise((resolve) => setTimeout(resolve, 5_000)),
2344
+ ]);
2345
+ }
2346
+ try { processRef.turn.close?.(); } catch {}
2347
+ try { processRef.abortController.abort(); } catch {}
2348
+ }
2349
+
2350
+ /** Stop one live background task (the panel's per-task Stop button). */
2351
+ async function stopBackgroundTask(taskId) {
2352
+ const normalized = String(taskId || '').trim();
2353
+ if (!normalized || !proc?.turn?.stopTask) return false;
2354
+ try {
2355
+ await proc.turn.stopTask(normalized);
2356
+ return true;
2357
+ } catch (error) {
2358
+ dbg('stopTask failed', normalized, error?.message || String(error));
2359
+ return false;
2360
+ }
2361
+ }
2362
+
2363
+ /**
2364
+ * Targeted subagent stop: the relay's subagentRunId IS the spawning
2365
+ * tool_use id, and task_started/task_progress report the same id per task —
2366
+ * so a BACKGROUNDED subagent maps to a stoppable SDK task. In-turn
2367
+ * subagents have no task id and remain full-turn-Stop only.
2368
+ */
2369
+ async function stopBackgroundTaskByToolUseId(toolUseId) {
2370
+ const normalized = String(toolUseId || '').trim();
2371
+ if (!normalized || !proc) return false;
2372
+ for (const [taskId, task] of proc.liveTasks.entries()) {
2373
+ if (String(task?.toolUseId || '').trim() === normalized) {
2374
+ return stopBackgroundTask(taskId);
2375
+ }
2376
+ }
2377
+ return false;
2378
+ }
2379
+
2380
+ return {
2381
+ handlePendingPayload,
2382
+ getActiveQueueMessageId,
2383
+ getActiveQueueMessageIds,
2384
+ isTurnActive,
2385
+ stopBackgroundTask,
2386
+ stopBackgroundTaskByToolUseId,
2387
+ shutdown,
2388
+ // Test seams / observability.
2389
+ _getProcess: () => proc,
2390
+ };
2391
+ }