@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,1729 @@
1
+ // Owns the CopilotClient / CopilotSession lifecycle for one relay conversation
2
+ // and runs the delivered turns through them.
3
+ //
4
+ // Contract-wise this is the Cursor/Claude worker's `handlePendingPayload`
5
+ // runner with a different engine underneath: same relay channels, same
6
+ // response/requeue/abort semantics, same terminal-error record shape. The
7
+ // engine-specific part is that the Copilot SDK does not hand back an async
8
+ // iterator for a turn — `send()` resolves as soon as the prompt is accepted —
9
+ // so the turn is driven as a small state machine over the session's event
10
+ // callback. `sendAndWait()` is deliberately unused: it has a hard 60s internal
11
+ // timeout after which it merely stops waiting, which would silently strand
12
+ // every long turn.
13
+ //
14
+ // ## Self-initiated turns
15
+ //
16
+ // The runtime does not only answer prompts. A detached background shell
17
+ // (`bash{mode:"async", detach:true}`) settles on its own clock, and the runtime
18
+ // then re-invokes the model with NO prompt behind it — a `system.notification`
19
+ // followed by a fresh `assistant.turn_start`, tool calls and a durable
20
+ // `assistant.message`. Live burn-in (session `10a1a9ad`, 2026-08-31: "set a
21
+ // timer to 1 minute") caught the whole of that second turn being dropped
22
+ // because no relay row was open to publish it into, and the user never saw the
23
+ // answer they had been promised.
24
+ //
25
+ // So a turn here is one of two kinds:
26
+ //
27
+ // - `delivered` — a queue row arrived, `runTurn` sends its prompt;
28
+ // - `continuation` — the runtime started work by itself. The worker mints a
29
+ // synthetic queue row (`POST /api/continuation-turn`) and runs the SAME
30
+ // state machine over the events, so the turn gets the full relay surface:
31
+ // stream, thoughts, activity, questions, usage and a response of its own.
32
+ // Actions produced before the row exists are buffered and flushed in order.
33
+ //
34
+ // Three rules keep that safe, and each is enforced in `routeEvent`:
35
+ //
36
+ // 1. **Replay is not new work.** `session.resume` can replay persisted history
37
+ // through the same callback; `createReplayGate` drops it (see that module
38
+ // for why `resumeTime` and `eventCount` are used together).
39
+ // 2. **Liveness pins the runtime.** Idle shutdown must not stop a runtime that
40
+ // has a detached shell running or a settled shell's continuation still due
41
+ // — stopping it kills the shell. `createBackgroundShellTracker` supplies
42
+ // the set; `backgroundTaskTimeoutMs` caps how long it may pin.
43
+ // 3. **A continuation is a turn like any other.** It ends on `session.idle`,
44
+ // its usage is captured and posted, and a user message delivered while it
45
+ // runs is steered into it (answered from its own prompt segment) rather
46
+ // than cross-published into the continuation's row.
47
+ import {
48
+ USER_INPUT_UNSUPPORTED_ANSWER,
49
+ classifyCopilotSessionError,
50
+ classifyCopilotTurnException,
51
+ copilotAgentModeForRelayMode,
52
+ createCopilotPermissionHandler,
53
+ isReadOnlyPermissionRequest,
54
+ isSessionNotFoundError,
55
+ observeRuntimeExit,
56
+ resolveCopilotSdkPaths,
57
+ startCopilotClient,
58
+ } from './copilot-sdk-adapter.mjs';
59
+ import { buildCopilotMessageOptions } from './copilot-attachments.mjs';
60
+ import { resolveCopilotProviderConfig } from './copilot-byok-provider.mjs';
61
+ import {
62
+ createBackgroundShellTracker,
63
+ createReplayGate,
64
+ describeSettledShell,
65
+ isContinuationOpeningEvent,
66
+ } from './copilot-continuation-signals.mjs';
67
+ import { createCopilotEventNormalizer } from './copilot-sdk-event-normalizer.mjs';
68
+ import { createCopilotQuestionBridge } from './copilot-question-bridge.mjs';
69
+ import {
70
+ EXIT_PLAN_BOARD_POSTED_FEEDBACK,
71
+ EXIT_PLAN_NO_BOARD_FEEDBACK,
72
+ buildCopilotPlanReadyBoardPayload,
73
+ planTextFromExitRequest,
74
+ shouldPostPlanBoard,
75
+ } from './copilot-plan-board.mjs';
76
+ import {
77
+ createCopilotPromptContextBuilder,
78
+ createPreviewInstructionsProvider,
79
+ loadDefaultRelayToolInstructions,
80
+ withRelayContext,
81
+ } from './copilot-prompt-context.mjs';
82
+ import { EMPTY_TURN_COMPLETION_NOTE } from '../../shared/empty-turn-completion.mjs';
83
+
84
+ // How long the runtime may sit with no session activity before the worker
85
+ // closes it. The worker process itself stays up and reconnects lazily on the
86
+ // next delivery — same trade the Claude worker makes (`gracefulShutdown('idle')`
87
+ // ends the CLI, not the worker), because holding the ws link is cheap while
88
+ // holding a runtime subprocess per idle conversation is not.
89
+ const DEFAULT_IDLE_SHUTDOWN_MS = 10 * 60_000;
90
+ const DEFAULT_LIFECYCLE_POLL_MS = 5_000;
91
+ // Mirrors the Cursor adapter's `stallTimeoutMs`. Emphatically NOT 0: the
92
+ // worker's 10s heartbeat keeps renewing the relay's processing lease
93
+ // (messages-routes.mjs), so without a stall ceiling a turn whose runtime went
94
+ // quiet holds its queue row open indefinitely and no watchdog can free it.
95
+ const DEFAULT_TURN_STALL_TIMEOUT_MS = 120_000;
96
+ /**
97
+ * How long live background shells ALONE may keep the runtime up (0 = no limit).
98
+ *
99
+ * Deliberately not the relay's `background_task_timeout_minutes` slider, whose
100
+ * default is 0/unlimited: that slider governs Claude's background tasks, which
101
+ * have ids, a composer panel and a stop button, so "no limit" there is a choice
102
+ * the user can see and undo. A Copilot detached shell has none of that — the
103
+ * runtime exposes no RPC to stop one and the relay has no surface listing them
104
+ * — so an unlimited default would let a single forgotten `sleep 99999` pin a
105
+ * runtime subprocess for the life of the relay with nothing to point at. 30
106
+ * minutes is well past any timer a user would sit and wait for, and the cap
107
+ * only ever costs the shell, never a turn.
108
+ */
109
+ const DEFAULT_BACKGROUND_TASK_TIMEOUT_MS = 30 * 60_000;
110
+ /**
111
+ * How long after a shell settles the runtime is held up waiting for the
112
+ * continuation it should trigger.
113
+ *
114
+ * The live capture had 3ms between the `system.notification` and the
115
+ * `assistant.turn_start`. This window only has to survive a runtime that
116
+ * notifies and then decides there is nothing to say — mirrors the Claude
117
+ * worker's `notificationGraceMs`, same value.
118
+ */
119
+ const DEFAULT_CONTINUATION_GRACE_MS = 60_000;
120
+ /** Retry spacing for the synthetic-row registration (3 attempts). */
121
+ const DEFAULT_CONTINUATION_RETRY_DELAY_MS = 500;
122
+ /** How long a continuation's actions may buffer before its row is abandoned. */
123
+ const CONTINUATION_REGISTRATION_TIMEOUT_MS = 10_000;
124
+ /** Cap on activity lines carried between turns, so a chatty runtime cannot grow them. */
125
+ const MAX_PENDING_ACTIVITIES = 20;
126
+
127
+ /**
128
+ * Appended to the partial answer when the RUNTIME interrupted the turn on its
129
+ * own (as opposed to the user aborting through the relay). The row has to be
130
+ * settled by this worker in that case, because nothing server-side is waiting
131
+ * to settle it.
132
+ */
133
+ export const RUNTIME_INTERRUPTED_NOTE =
134
+ 'System note: the Copilot runtime interrupted this turn before it finished. '
135
+ + 'Resend the message to continue.';
136
+
137
+ /**
138
+ * Published to a steered queue row whose prompt the runtime accepted but never
139
+ * opened work on before the interaction ended. The prompt is still queued
140
+ * INSIDE the runtime, so it will be answered at the start of the next turn —
141
+ * requeuing the row would run it twice. Mirrors the Claude worker's
142
+ * handed-off-context note, which exists for the same reason.
143
+ */
144
+ export const STEERED_ROW_MERGED_NOTE =
145
+ '_(This message was delivered while the previous turn was still running; the reply continues in '
146
+ + 'the next turn.)_';
147
+
148
+ /**
149
+ * The `trigger` reported to `POST /api/continuation-turn`, matching the value
150
+ * the Claude worker sends so the relay's `CONTINUATION … trigger=` log line and
151
+ * any future per-trigger handling read the same for both engines.
152
+ */
153
+ export const CONTINUATION_TRIGGER = 'background_task';
154
+
155
+ /**
156
+ * Compaction / infinite-session policy.
157
+ *
158
+ * These are the runtime's OWN documented defaults for `InfiniteSessionConfig`
159
+ * (enabled, background compaction at 0.80 of the context window, blocking
160
+ * compaction at 0.95) — they are set explicitly rather than left unset so a
161
+ * future change to the runtime's defaults cannot silently move the point at
162
+ * which a long relay conversation starts compacting. Compaction is what makes
163
+ * a resumable, long-lived relay conversation possible at all: the alternative
164
+ * is a turn that fails on context overflow with the whole history intact and
165
+ * no way forward.
166
+ */
167
+ export const DEFAULT_INFINITE_SESSION_CONFIG = Object.freeze({
168
+ enabled: true,
169
+ backgroundCompactionThreshold: 0.8,
170
+ bufferExhaustionThreshold: 0.95,
171
+ });
172
+
173
+ /** `steerIntoActiveTurn` could not adopt the row; run it as a normal turn. */
174
+ const NOT_STEERED = Symbol('not-steered');
175
+
176
+ export function createCopilotSdkSessionRunner({
177
+ api,
178
+ sdkSessionId,
179
+ cwd,
180
+ defaultModel = '',
181
+ controlPoller = null,
182
+ env = process.env,
183
+ clientName = 'copilot-web-relay',
184
+ logLevel = 'error',
185
+ // Injection seams. Tests pass a fake client/session pair; nothing in this
186
+ // module imports the real SDK (that lives in copilot-sdk-adapter.mjs).
187
+ resolvePathsImpl = resolveCopilotSdkPaths,
188
+ startClientImpl = startCopilotClient,
189
+ createNormalizerImpl = createCopilotEventNormalizer,
190
+ buildMessageOptionsImpl = buildCopilotMessageOptions,
191
+ // BYOK: `COPILOT_PROVIDER_*` in this worker's env become
192
+ // `SessionConfig.provider`. Injected so tests can drive the branch without
193
+ // mutating process.env.
194
+ resolveProviderConfigImpl = resolveCopilotProviderConfig,
195
+ // Threading seam for `MessageOptions.mode` ("enqueue" | "immediate").
196
+ //
197
+ // A BYOK probe against runtime 1.0.82 ran a mid-turn send three ways —
198
+ // "enqueue", "immediate" and unset — and all three behaved IDENTICALLY: the
199
+ // send resolves in ~2ms with a message id, the prompt is queued
200
+ // (`pending_messages.modified`), the in-flight model call runs to completion
201
+ // untouched, and the prompt is picked up at the NEXT model-call boundary with
202
+ // its own `user.message`. Neither mode interrupts anything. "enqueue" is the
203
+ // documented default and preserves FIFO order, which is what the relay queue
204
+ // contract wants, so it is what this sends. See §5 of the plan doc.
205
+ resolveSendModeImpl = () => 'enqueue',
206
+ // Interactive surfaces. Tests inject a fake bridge; nothing here reaches the
207
+ // relay without one.
208
+ createQuestionBridgeImpl = createCopilotQuestionBridge,
209
+ questionPollMs = undefined,
210
+ questionTimeoutMs = undefined,
211
+ // Preview-lane guidance. Advisory — a failure costs the block, not the turn.
212
+ relayToolInstructions = undefined,
213
+ getPreviewInstructionsImpl = undefined,
214
+ infiniteSessionConfig = DEFAULT_INFINITE_SESSION_CONFIG,
215
+ idleShutdownMs = DEFAULT_IDLE_SHUTDOWN_MS,
216
+ lifecyclePollMs = DEFAULT_LIFECYCLE_POLL_MS,
217
+ // 0 disables (matching the background-task timeout's 0 = no-limit
218
+ // convention). When set, a turn that goes this long without a single event
219
+ // fails terminally instead of holding the queue row until the relay's own
220
+ // delivery watchdog gives up.
221
+ turnStallTimeoutMs = DEFAULT_TURN_STALL_TIMEOUT_MS,
222
+ // How long live detached shells alone may keep the runtime up (0 = no
223
+ // limit). Read through a getter, like the Claude worker's
224
+ // `getBackgroundTaskTimeoutMs`, so a future settings push can move it without
225
+ // a worker restart.
226
+ getBackgroundTaskTimeoutMs = () => DEFAULT_BACKGROUND_TASK_TIMEOUT_MS,
227
+ continuationGraceMs = DEFAULT_CONTINUATION_GRACE_MS,
228
+ continuationRetryDelayMs = DEFAULT_CONTINUATION_RETRY_DELAY_MS,
229
+ // How long a settled continuation waits for its row before giving up on it.
230
+ // Must outlast the registration's own retries; a test shortens it.
231
+ continuationRegistrationTimeoutMs = CONTINUATION_REGISTRATION_TIMEOUT_MS,
232
+ dbg = () => {},
233
+ } = {}) {
234
+ let client = null;
235
+ let session = null;
236
+ let sdkPaths = null;
237
+ let appliedModel = '';
238
+ let activeTurn = null;
239
+ let lifecycleTimer = null;
240
+ let lastActivityAt = Date.now();
241
+ let lastTurnUsage = null;
242
+ // The snapshot object already handed to the ingest. Identity, not a flag, so
243
+ // a turn that captured nothing new cannot re-post the previous turn's numbers
244
+ // (every settle path runs `postTurnUsage`, including the ones that publish no
245
+ // fresh usage at all).
246
+ let postedTurnUsage = null;
247
+ // The in-flight ingest POST, exposed only as a test seam. Never awaited by
248
+ // the turn path — that is the entire point of it.
249
+ let usagePostChain = Promise.resolve();
250
+ let starting = null;
251
+ let disposed = false;
252
+ let detachRuntimeExit = () => {};
253
+ // Prompts this worker sent that the runtime accepted but had not started work
254
+ // on when the interaction ended. They stay in the runtime's pending queue and
255
+ // are picked up FIRST in the next interaction, ahead of that turn's own
256
+ // prompt — so the next turn's segments are shifted by this many, and without
257
+ // it the primary row would be answered with a leftover prompt's reply.
258
+ let carriedPrompts = 0;
259
+ // Blocking handlers currently waiting on a human (`ask_user`, an ask-mode
260
+ // tool approval). The runtime emits NO events while blocked in one, and the
261
+ // question timeout is 8 hours against a 120s stall ceiling — so without this
262
+ // every unanswered card would fail its row after two minutes and then hand
263
+ // the human's eventual answer to a runtime whose row is already settled.
264
+ // Same guard the Cursor worker's `hasPendingClientWork` provides.
265
+ let pendingHumanRequests = 0;
266
+ // The `SessionConfig.provider` block this session was BUILT with, or null for
267
+ // a hosted (`github`) session. Set by `buildSessionConfig` rather than by a
268
+ // second throwaway resolve here, so "is this BYOK?" and "what did the runtime
269
+ // actually get?" can never disagree — which matters because the block's token
270
+ // ceilings are model-specific and the session is rebuilt when the model
271
+ // changes.
272
+ let byokProvider = null;
273
+ // Drops the history a `session.resume` replays through the live callback, so
274
+ // a two-day-old `assistant.message` can never mint a continuation row.
275
+ const replayGate = createReplayGate();
276
+ // The detached shells this session has running. The lifecycle's pin, and the
277
+ // reason a settled one is worth waiting for.
278
+ const backgroundShells = createBackgroundShellTracker();
279
+ // When a shell settled and the continuation it should trigger has not opened
280
+ // yet. Holds the runtime for `continuationGraceMs`; cleared by the
281
+ // continuation opening, or by the grace expiring on a runtime that decided it
282
+ // had nothing to say.
283
+ let continuationDueSince = 0;
284
+ // Transcript lines produced between turns (a settled shell's notification).
285
+ // They belong to the turn they trigger, so they are carried into it rather
286
+ // than dropped — the same trade the Claude worker's `pendingActivities`
287
+ // makes.
288
+ let pendingActivities = [];
289
+ // The relay mode of the last delivered turn. A self-initiated turn has no
290
+ // delivery to read a mode off, and it is a continuation OF that turn's work,
291
+ // so it inherits it — which is what keeps the permission handler and the
292
+ // plan-board gating behaving the same either side of a background wait.
293
+ let lastRelayMode = 'agent';
294
+
295
+ async function whileAwaitingHuman(run) {
296
+ pendingHumanRequests += 1;
297
+ try {
298
+ return await run();
299
+ } finally {
300
+ pendingHumanRequests -= 1;
301
+ // The clock restarts from the answer, not from before the wait.
302
+ touch();
303
+ activeTurn?.armStall?.();
304
+ }
305
+ }
306
+
307
+ // The relay question bridge serves `ask_user` and (in ask mode) tool
308
+ // approvals. `getActiveMessage` must resolve to the row that is CURRENTLY
309
+ // `processing`, because `/api/relay-question` 409s otherwise.
310
+ const questionBridge = createQuestionBridgeImpl({
311
+ api,
312
+ sdkSessionId,
313
+ // A continuation's message has no `id` until its synthetic row is
314
+ // registered; posting a card against a null id would 409. Reporting "no
315
+ // active message" instead lets the bridge take its own degraded path.
316
+ getActiveMessage: () => (activeTurn?.message?.id ? activeTurn.message : null),
317
+ ...(questionPollMs === undefined ? {} : { questionPollMs }),
318
+ ...(questionTimeoutMs === undefined ? {} : { questionTimeoutMs }),
319
+ dbg,
320
+ });
321
+
322
+ const buildRelayContextPrefix = createCopilotPromptContextBuilder({
323
+ toolInstructions: relayToolInstructions === undefined
324
+ ? loadDefaultRelayToolInstructions({ env })
325
+ : relayToolInstructions,
326
+ getPreviewInstructions: getPreviewInstructionsImpl === undefined
327
+ ? createPreviewInstructionsProvider({ api })
328
+ : getPreviewInstructionsImpl,
329
+ });
330
+ // Every event is handled on one chain so the relay POSTs for a turn land in
331
+ // the order the runtime produced them; the SDK's callback is synchronous and
332
+ // would otherwise interleave awaits.
333
+ let dispatchChain = Promise.resolve();
334
+
335
+ function touch() {
336
+ lastActivityAt = Date.now();
337
+ }
338
+
339
+ // ---------------------------------------------------------------- publish --
340
+
341
+ async function postActivity(message, text, subagentRunId = null) {
342
+ if (!text) return;
343
+ await api('POST', '/api/activity', {
344
+ messageId: message.id,
345
+ conversationId: message.conversationId,
346
+ mode: message.relayMode || 'agent',
347
+ text,
348
+ ...(subagentRunId ? { subagentRunId } : {}),
349
+ }).catch(() => {});
350
+ }
351
+
352
+ async function dispatchAction(message, action, state) {
353
+ const { channel, payload } = action;
354
+ if (channel === 'init') {
355
+ // Inert: the response's model is read straight off the normalizer when
356
+ // the turn settles, so there is nothing to mirror into turn state here.
357
+ return;
358
+ }
359
+ if (channel === 'stream') {
360
+ // Only main-thread text can stand in for the answer on the abort/error
361
+ // fallback paths; subagent text would publish as the reply.
362
+ if (!payload.subagentRunId) state.lastStreamedText = payload.text;
363
+ await api('POST', '/api/stream', {
364
+ messageId: message.id,
365
+ conversationId: message.conversationId,
366
+ mode: message.relayMode || 'agent',
367
+ text: payload.text,
368
+ done: payload.done === true,
369
+ ...(payload.subagentRunId ? { subagentRunId: payload.subagentRunId } : {}),
370
+ }).catch(() => {});
371
+ return;
372
+ }
373
+ if (channel === 'thought') {
374
+ await api('POST', '/api/thought', {
375
+ messageId: message.id,
376
+ conversationId: message.conversationId,
377
+ mode: message.relayMode || 'agent',
378
+ reasoningId: payload.reasoningId,
379
+ text: payload.text,
380
+ done: payload.done === true,
381
+ ...(payload.subagentRunId ? { subagentRunId: payload.subagentRunId } : {}),
382
+ }).catch(() => {});
383
+ return;
384
+ }
385
+ if (channel === 'activity') {
386
+ await postActivity(message, payload.text, payload.subagentRunId);
387
+ return;
388
+ }
389
+ if (channel === 'subagent') {
390
+ // Same body as every sibling worker's, so the lane bubbles, the
391
+ // `subagent_status` broadcast and the UI's grouping behave identically
392
+ // whichever provider produced the run.
393
+ await api('POST', '/api/subagent-run', {
394
+ messageId: message.id,
395
+ conversationId: message.conversationId,
396
+ subagentRunId: payload.subagentRunId,
397
+ ...(payload.parentSubagentId ? { parentSubagentId: payload.parentSubagentId } : {}),
398
+ ...(payload.displayName ? { displayName: payload.displayName } : {}),
399
+ status: payload.status,
400
+ }).catch(() => {});
401
+ }
402
+ }
403
+
404
+ /**
405
+ * Force-close any subagent still marked running.
406
+ *
407
+ * The normalizer closes strays when it builds a terminal `result`, but the
408
+ * paths that kill a turn WITHOUT one — a user abort, the runtime exiting, a
409
+ * thrown exception — never get there. The relay only reconciles open runs
410
+ * when the queue row is FAILED, so on the abort path (where the row is
411
+ * settled server-side) an un-closed run would render as a bubble spinning
412
+ * forever.
413
+ */
414
+ async function closeStraySubagentRuns(turn) {
415
+ const runs = turn?.normalizer?.activeSubagentRuns?.() || [];
416
+ for (const run of runs) {
417
+ await emitAction(turn, {
418
+ channel: 'subagent',
419
+ payload: {
420
+ subagentRunId: run.subagentRunId,
421
+ parentSubagentId: null,
422
+ displayName: run.displayName,
423
+ status: 'failed',
424
+ },
425
+ });
426
+ }
427
+ }
428
+
429
+ /**
430
+ * Publish an action, or hold it if the turn has no queue row yet.
431
+ *
432
+ * The single gate every producer goes through. A continuation's row is
433
+ * created asynchronously, and a POST carrying `messageId: null` is not merely
434
+ * useless — the relay's activity/stream routes key on it, so it would be
435
+ * attributed to nothing at all.
436
+ */
437
+ async function emitAction(turn, action) {
438
+ if (!turn.registered) {
439
+ turn.bufferedActions.push(action);
440
+ return;
441
+ }
442
+ await dispatchAction(turn.message, action, turn.state);
443
+ }
444
+
445
+ async function publishFinalStream(message, text) {
446
+ await api('POST', '/api/stream', {
447
+ messageId: message.id,
448
+ conversationId: message.conversationId,
449
+ mode: message.relayMode || 'agent',
450
+ text: String(text || ''),
451
+ done: true,
452
+ }).catch(() => {});
453
+ }
454
+
455
+ async function publishResponse(message, { text, model, terminalError = null, modelOrigin }) {
456
+ await api('POST', '/api/response', {
457
+ messageId: message.id,
458
+ conversationId: message.conversationId,
459
+ text: String(text || ''),
460
+ model: model || null,
461
+ modelOrigin: modelOrigin
462
+ || (String(message?.model || '').trim().toLowerCase() === 'auto' ? 'auto' : 'manual'),
463
+ ...(terminalError ? { terminalError } : {}),
464
+ }).catch(async () => {
465
+ await api('POST', '/api/requeue', { messageId: message.id }).catch(() => {});
466
+ });
467
+ }
468
+
469
+ function terminalErrorRecord(message, classified) {
470
+ return {
471
+ kind: 'copilot-turn-failed',
472
+ code: classified.code,
473
+ stableCode: classified.stableCode,
474
+ message: classified.text,
475
+ failedAt: new Date().toISOString(),
476
+ queueMessageId: String(message.id || '') || null,
477
+ };
478
+ }
479
+
480
+ /**
481
+ * Per-turn usage capture, recorded on the runner.
482
+ *
483
+ * Synchronous and side-effect-only: it decides WHAT to report, never when.
484
+ * The POST is `postTurnUsage`'s job and runs after the row is published.
485
+ */
486
+ function captureTurnUsage(message, result) {
487
+ const usage = result?.usage || null;
488
+ const contextUsage = result?.contextUsage || null;
489
+ if (!usage && !contextUsage) return;
490
+ lastTurnUsage = {
491
+ conversationId: message.conversationId,
492
+ sdkSessionId,
493
+ messageId: message.id,
494
+ model: result?.model || appliedModel || defaultModel || '',
495
+ usage,
496
+ contextUsage,
497
+ capturedAt: new Date().toISOString(),
498
+ };
499
+ dbg('turn usage', JSON.stringify({
500
+ model: lastTurnUsage.model,
501
+ inputTokens: usage?.inputTokens ?? null,
502
+ outputTokens: usage?.outputTokens ?? null,
503
+ // The premium MULTIPLIER, not money.
504
+ cost: usage?.cost ?? null,
505
+ // Real spend, and the field a usage card should show.
506
+ totalNanoAiu: usage?.totalNanoAiu ?? null,
507
+ modelCalls: usage?.modelCalls ?? null,
508
+ subagentModelCalls: usage?.subagentModelCalls ?? null,
509
+ timeToFirstTokenMs: usage?.timeToFirstTokenMs ?? null,
510
+ contextTokens: contextUsage?.currentTokens ?? null,
511
+ // Overage lives at `quotaSnapshots.cfi_overage`; `account.getQuota()`
512
+ // reads a stale cache and will not show it.
513
+ hasQuotaSnapshots: !!usage?.quotaSnapshots,
514
+ cfiOverage: usage?.quotaSnapshots?.cfi_overage ?? null,
515
+ }));
516
+ }
517
+
518
+ /**
519
+ * Report the captured turn usage to the relay's `/api/copilot-plan-usage`
520
+ * ingest. Fire-and-forget, deliberately, and always AFTER the row has been
521
+ * published.
522
+ *
523
+ * The plan card's meters come from the account-level quota API the relay
524
+ * fetches itself, and those already cover SDK sessions. What only the worker
525
+ * can see is the per-turn detail: `totalNanoAiu` (real spend — the event's
526
+ * `cost` is the premium multiplier, not money) and
527
+ * `quotaSnapshots.cfi_overage` (overage, invisible to `account.getQuota()`'s
528
+ * cached read). None of that is worth one millisecond of a finished reply.
529
+ *
530
+ * Awaiting it was actively dangerous: by this point the stall watchdog is
531
+ * disarmed and the relay client has no request timeout, so an unresponsive
532
+ * relay could hold a COMPLETED turn — its text already generated, its queue
533
+ * row still open — for as long as the socket stayed up. The result is unused
534
+ * and the failure is already swallowed, so there was nothing to wait for.
535
+ *
536
+ * BYOK sessions do not post at all: they spend the user's own OpenAI key
537
+ * rather than Copilot quota, and their usage events report `cost: 0`, so
538
+ * their numbers would only mislead on a card about the Copilot plan.
539
+ */
540
+ function postTurnUsage() {
541
+ if (byokProvider || !lastTurnUsage || lastTurnUsage === postedTurnUsage) return;
542
+ postedTurnUsage = lastTurnUsage;
543
+ // Held so a test (and only a test) can await the settle; nothing in the
544
+ // turn path ever reads it.
545
+ usagePostChain = api('POST', '/api/copilot-plan-usage', lastTurnUsage)
546
+ .catch((error) => { dbg('usage ingest failed', error?.message || String(error)); });
547
+ }
548
+
549
+ // ---------------------------------------------------------------- session --
550
+
551
+ function routeEvent(event) {
552
+ // A replayed event is history the relay already has. It must not touch the
553
+ // idle clock, the shell set, an active turn's normalizer, or — the reason
554
+ // this gate exists at all — open a continuation row for work that finished
555
+ // days ago.
556
+ if (replayGate.isReplay(event)) {
557
+ dbg('dropping a replayed event', String(event?.type || ''), String(event?.timestamp || ''));
558
+ return;
559
+ }
560
+ touch();
561
+ observeBackgroundShells(event);
562
+ let turn = activeTurn;
563
+ if (!turn) {
564
+ // The runtime started work with no row open. Anything that is not the
565
+ // START of work (terminators, connection bookkeeping) stays a no-op:
566
+ // opening a row for one would leave a synthetic turn in the transcript
567
+ // that only the stall watchdog could close.
568
+ if (!isContinuationOpeningEvent(event)) return;
569
+ turn = openContinuationTurn();
570
+ }
571
+ // `activeTurn` is deliberately still set (and still settled) while a turn
572
+ // publishes its response, so a continuation cannot open inside that window
573
+ // and clobber the row being written. Events landing there are dropped by
574
+ // `handleTurnEvent`'s settled guard — and cost nothing, because the opener
575
+ // allowlist spans the whole of a turn: the next `assistant.message` or
576
+ // `tool.execution_start` opens the continuation once the row is closed,
577
+ // with the normalizer still capturing the reply text.
578
+ dispatchChain = dispatchChain
579
+ .then(() => handleTurnEvent(turn, event))
580
+ .catch((error) => { dbg('event dispatch failed', error?.message || String(error)); });
581
+ }
582
+
583
+ /**
584
+ * Keep the detached-shell set current and note when one settles.
585
+ *
586
+ * Runs for every live event, in or out of a turn: shells are OPENED inside a
587
+ * delivered turn (that is where the model calls `bash`) and SETTLE outside
588
+ * one, which is the whole asymmetry this fix exists for.
589
+ */
590
+ function observeBackgroundShells(event) {
591
+ let changed;
592
+ try {
593
+ changed = backgroundShells.observe(event);
594
+ } catch (error) {
595
+ dbg('background shell tracking failed', String(event?.type || ''), error?.message || String(error));
596
+ return;
597
+ }
598
+ for (const shell of changed.opened) {
599
+ dbg('detached shell started', shell.shellId, shell.description || '(no description)');
600
+ }
601
+ for (const shell of changed.settled) {
602
+ dbg('detached shell settled', shell.shellId);
603
+ const note = describeSettledShell(shell);
604
+ // Only carried when there is no turn to publish into: inside a turn the
605
+ // normalizer already narrates the `read_bash` that reads the output.
606
+ if (note && !activeTurn && pendingActivities.length < MAX_PENDING_ACTIVITIES) {
607
+ pendingActivities.push(note);
608
+ }
609
+ }
610
+ // Only the runtime's own `system.notification` heralds a continuation. A
611
+ // `read_bash` that reports an exit code closes the same shell, but it
612
+ // happens inside a turn that already knows — pinning on it would hold the
613
+ // runtime for the grace window after every ordinary background command.
614
+ //
615
+ // Set even when a turn is active: the gap that matters is the one AFTER
616
+ // that turn closes.
617
+ if (changed.heralded) continuationDueSince = Date.now();
618
+ }
619
+
620
+ async function handleTurnEvent(turn, event) {
621
+ if (turn.settled) return;
622
+ turn.armStall?.();
623
+ let actions = [];
624
+ try {
625
+ actions = turn.normalizer.normalize(event);
626
+ } catch (error) {
627
+ dbg('normalize failed', String(event?.type || ''), error?.message || String(error));
628
+ return;
629
+ }
630
+ for (const action of actions) {
631
+ if (action.channel === 'result') {
632
+ turn.result = action.payload;
633
+ turn.settle();
634
+ return;
635
+ }
636
+ // A continuation's actions buffer until its synthetic row exists; the
637
+ // registration drains them in arrival order and only then flips
638
+ // `registered`, so a later action can never overtake an earlier one.
639
+ // Delivered turns are registered from birth and take the direct path.
640
+ await emitAction(turn, action);
641
+ }
642
+ }
643
+
644
+ /** Publish everything a not-yet-registered turn buffered, in order. */
645
+ async function flushBufferedActions(turn) {
646
+ while (turn.bufferedActions.length) {
647
+ const batch = turn.bufferedActions.splice(0);
648
+ for (const action of batch) {
649
+ await dispatchAction(turn.message, action, turn.state).catch(() => {});
650
+ }
651
+ }
652
+ }
653
+
654
+ function buildSessionConfig(model, relayMode) {
655
+ // Built once per session, not per request. Reads the mode off the LIVE turn
656
+ // rather than closing over the one the session was built with, because one
657
+ // session serves every turn and the user can switch modes between them.
658
+ const decidePermission = createCopilotPermissionHandler({
659
+ // Only the ask-mode branch actually blocks on a human; wrapping it keeps
660
+ // the stall watchdog off a turn where someone is deciding.
661
+ bridge: {
662
+ askToolApproval: (permissionRequest, options) => whileAwaitingHuman(
663
+ () => questionBridge.askToolApproval(permissionRequest, options),
664
+ ),
665
+ },
666
+ getRelayMode: () => activeTurn?.message?.relayMode || relayMode,
667
+ // An aborted turn must not leave the human staring at a card whose answer
668
+ // nothing will read; the bridge times the card out instead.
669
+ getSignal: () => activeTurn?.abortController?.signal || null,
670
+ dbg,
671
+ });
672
+ // BYOK sessions must carry their provider IN the session config: the
673
+ // runtime's `COPILOT_PROVIDER_*` startup layer does not run for
674
+ // SDK-created sessions, so without this an OpenAI-provider conversation
675
+ // would silently run on hosted Copilot models instead. Null for every
676
+ // hosted (`github`) session, which is the common case.
677
+ //
678
+ // The resolved block is remembered: its token ceilings describe THIS model,
679
+ // and `session.setModel()` cannot update them (see `applyModel`).
680
+ const provider = resolveProviderConfigImpl({ env, model: model || defaultModel, dbg });
681
+ byokProvider = provider;
682
+ return {
683
+ // The relay session id IS the SDK session id, so the runtime's own state
684
+ // under ~/.copilot/session-state/<id> is addressable by conversation and
685
+ // survives worker restarts without a side table.
686
+ sessionId: sdkSessionId,
687
+ ...(model ? { model } : {}),
688
+ ...(provider ? { provider } : {}),
689
+ // Not a default: without this the SDK emits no deltas at all and the
690
+ // transcript only updates when the whole message lands.
691
+ streaming: true,
692
+ workingDirectory: cwd,
693
+ clientName,
694
+ // The runtime's own defaults, pinned. See DEFAULT_INFINITE_SESSION_CONFIG.
695
+ ...(infiniteSessionConfig ? { infiniteSessions: { ...infiniteSessionConfig } } : {}),
696
+ onEvent: routeEvent,
697
+ // Permission policy follows the conversation's relay mode: agent and
698
+ // autopilot auto-approve, plan denies non-read tools with feedback, and
699
+ // ask asks the human through a relay question card. The handler reads the
700
+ // mode from the LIVE turn rather than closing over the mode the session
701
+ // was built with, because one session serves every turn of a conversation
702
+ // and the user can switch modes between them.
703
+ onPermissionRequest: async (request) => {
704
+ const decision = await decidePermission(request);
705
+ // Remember that this turn actually changed something: it is what tells
706
+ // a described plan apart from work already done, which is the
707
+ // difference between a useful handoff board and a nonsensical one.
708
+ if (decision?.kind === 'approve-once' && !isReadOnlyPermissionRequest(request) && activeTurn) {
709
+ activeTurn.acted = true;
710
+ }
711
+ return decision;
712
+ },
713
+ // `ask_user` → a relay question card. The runtime BLOCKS the turn on this
714
+ // handler, so it must always settle: a throw would fail the tool call
715
+ // silently, and a hang would hold the queue row until the delivery
716
+ // watchdog gives up. `wasFreeform` is required by `UserInputResponse` and
717
+ // the deserializer is strict, so it is always a real boolean.
718
+ onUserInputRequest: async (request) => {
719
+ try {
720
+ const { answer, wasFreeform } = await whileAwaitingHuman(() => questionBridge.askUserInput(request, {
721
+ signal: activeTurn?.abortController?.signal || null,
722
+ }));
723
+ return { answer, wasFreeform };
724
+ } catch (error) {
725
+ dbg('user input question failed', error?.message || String(error));
726
+ return { answer: USER_INPUT_UNSUPPORTED_ANSWER, wasFreeform: true };
727
+ }
728
+ },
729
+ // The agent finished planning. Post the board and REFUSE the exit:
730
+ // approving it tells the runtime the plan was accepted and the same turn
731
+ // rolls straight into implementing while the board sits unanswered.
732
+ onExitPlanModeRequest: async (request) => {
733
+ const posted = await publishPlanBoard(activeTurn, planTextFromExitRequest(request), 'exit_plan_mode');
734
+ return {
735
+ approved: false,
736
+ feedback: posted ? EXIT_PLAN_BOARD_POSTED_FEEDBACK : EXIT_PLAN_NO_BOARD_FEEDBACK,
737
+ };
738
+ },
739
+ // Structured elicitation has no relay card type of its own; declining is
740
+ // in-band and lets the model continue, which a hang would not.
741
+ onElicitationRequest: () => ({ action: 'decline' }),
742
+ };
743
+ }
744
+
745
+ /**
746
+ * Post the `plan_ready` board for a turn. Returns whether a board went out,
747
+ * which the exit-plan handler turns into the feedback the agent sees.
748
+ *
749
+ * Marks the turn so the completion path's text-shape fallback does not post a
750
+ * second board — the relay would dedupe it (`UNIQUE(message_id, board_type)`)
751
+ * but only after a pointless round trip.
752
+ */
753
+ async function publishPlanBoard(turn, planText, source) {
754
+ if (!turn?.message?.id) return false;
755
+ const payload = buildCopilotPlanReadyBoardPayload({ message: turn.message, planText, source });
756
+ if (!payload) return false;
757
+ // The failure is swallowed (a relay that refused the board must not fail a
758
+ // turn that otherwise succeeded) but it is REPORTED: telling the agent the
759
+ // plan is "shown to the user for review" when it is not would end the turn
760
+ // with the plan visible nowhere, and would also latch off the text-shape
761
+ // fallback that could still have posted it.
762
+ try {
763
+ await api('POST', '/api/relay-board', payload);
764
+ } catch (error) {
765
+ dbg('plan board publish failed', error?.message || String(error));
766
+ return false;
767
+ }
768
+ turn.planBoardPosted = true;
769
+ return true;
770
+ }
771
+
772
+ /**
773
+ * The runtime process died under us. Nothing the SDK is waiting on will ever
774
+ * resolve, so the active turn is failed terminally-but-retryably rather than
775
+ * left to hold its queue row open behind a heartbeat that keeps renewing the
776
+ * processing lease.
777
+ */
778
+ function handleRuntimeExit(detail) {
779
+ dbg('copilot runtime exited', detail);
780
+ const turn = activeTurn;
781
+ if (turn && !turn.settled) {
782
+ // handlePendingPayload's catch tears the runtime down and publishes the
783
+ // failure record.
784
+ turn.fail(new Error(`the Copilot runtime exited before the turn completed (${detail})`));
785
+ return;
786
+ }
787
+ // No turn to fail: drop the dead handles so the next delivery rebuilds
788
+ // instead of sending into a corpse.
789
+ void stopRuntime('runtime-exit').catch(() => {});
790
+ }
791
+
792
+ async function ensureClient() {
793
+ if (client) return client;
794
+ if (starting) return starting;
795
+ starting = (async () => {
796
+ sdkPaths = resolvePathsImpl({ env });
797
+ const started = await startClientImpl({ paths: sdkPaths, cwd, clientName, logLevel, dbg });
798
+ client = started.client;
799
+ detachRuntimeExit = observeRuntimeExit(client, handleRuntimeExit);
800
+ // Diagnostics only, and deliberately not awaited — see startCopilotClient.
801
+ Promise.resolve(started.versionReady)
802
+ .then((info) => {
803
+ if (info?.versionSkewWarning) dbg(info.versionSkewWarning);
804
+ else dbg(`copilot runtime ready (version ${info?.runtimeVersion || sdkPaths.version || 'unknown'})`);
805
+ })
806
+ .catch(() => {});
807
+ startLifecycleTimer();
808
+ return client;
809
+ })();
810
+ try {
811
+ return await starting;
812
+ } finally {
813
+ starting = null;
814
+ }
815
+ }
816
+
817
+ /**
818
+ * Best-effort model switch: a rejected switch must not fail the turn, it
819
+ * just runs on the session's current model (and says so in the log).
820
+ *
821
+ * This is the HOSTED path only. A BYOK session cannot switch this way — see
822
+ * `switchModel`.
823
+ */
824
+ async function applyModel(model) {
825
+ if (!model || typeof session?.setModel !== 'function') return;
826
+ try {
827
+ await session.setModel(model);
828
+ appliedModel = model;
829
+ } catch (error) {
830
+ dbg('copilot setModel failed', model, error?.message || String(error));
831
+ }
832
+ }
833
+
834
+ /**
835
+ * Switch a live session onto a different model.
836
+ *
837
+ * Hosted sessions just call `setModel()`. BYOK sessions cannot: their token
838
+ * ceilings live in `SessionConfig.provider`, which is fixed at session
839
+ * creation. Runtime 1.0.82 exposes **no** way to update it — `setModel()`
840
+ * takes reasoning/context options but no provider, there is no
841
+ * `setProvider`, and the one runtime registry-add RPC belongs to the
842
+ * experimental named-`providers`/`models` surface, which the runtime
843
+ * explicitly REJECTS when combined with the singular whole-session
844
+ * `provider` this worker uses.
845
+ *
846
+ * So a bare `setModel()` on a BYOK session leaves the previous model's
847
+ * ceilings in place, and both directions of that are harmful: ceilings too
848
+ * high turn compaction into hard API rejections, ceilings too low compact a
849
+ * conversation that had plenty of room left.
850
+ *
851
+ * The session is therefore disposed and rebuilt with freshly resolved
852
+ * ceilings. Nothing is lost: the relay session id IS the SDK session id, the
853
+ * runtime's state persists under it, and the rebuild takes the ordinary
854
+ * resume path — the same one every worker restart already uses.
855
+ */
856
+ async function switchModel(model, relayMode) {
857
+ if (!byokProvider) {
858
+ await applyModel(model);
859
+ return session;
860
+ }
861
+ dbg('rebuilding the copilot session for a BYOK model switch', model);
862
+ const closing = session;
863
+ session = null;
864
+ appliedModel = '';
865
+ // Disconnect first so the runtime is not holding two handles on one
866
+ // session id while the resume runs.
867
+ try { await closing?.disconnect?.(); } catch (error) {
868
+ dbg('session disconnect before model switch failed', error?.message || String(error));
869
+ }
870
+ return ensureSession(model, relayMode);
871
+ }
872
+
873
+ async function ensureSession(model, relayMode) {
874
+ await ensureClient();
875
+ if (!session) {
876
+ const config = buildSessionConfig(model, relayMode);
877
+ // Resume first, always. On a brand-new conversation this costs one
878
+ // failed RPC; on every other path (worker restart, idle shutdown,
879
+ // relay restart) it is the difference between continuing the
880
+ // conversation and silently starting a fresh one. A negative result is
881
+ // deliberately not cached: once `createSession` succeeds, the state
882
+ // exists and the NEXT reconnect must resume it.
883
+ let resumed = false;
884
+ try {
885
+ session = await client.resumeSession(sdkSessionId, config);
886
+ resumed = true;
887
+ dbg(`resumed copilot session ${sdkSessionId.slice(0, 8)}`);
888
+ } catch (error) {
889
+ // ONLY a definitive "no such session" may fall through to creating a
890
+ // blank one. A dropped connection or an unrecognised failure is
891
+ // transient, and starting fresh over live state would silently throw
892
+ // the conversation's whole history away — so it fails the turn
893
+ // instead, which is retryable and loses nothing.
894
+ if (!isSessionNotFoundError(error)) {
895
+ dbg('copilot session resume failed transiently', error?.message || String(error));
896
+ throw error;
897
+ }
898
+ dbg('copilot session state not found, creating', error?.message || String(error));
899
+ session = await client.createSession(config);
900
+ dbg(`created copilot session ${sdkSessionId.slice(0, 8)}`);
901
+ }
902
+ if (resumed) {
903
+ // A resumed session keeps whatever model it was created with —
904
+ // `config.model` is not guaranteed to be honoured on resume — so the
905
+ // requested model is applied explicitly rather than assumed. Assuming
906
+ // it is what made a mismatch permanent: `appliedModel` would already
907
+ // equal the request, so the per-turn switch below could never fire.
908
+ appliedModel = '';
909
+ await applyModel(model);
910
+ } else {
911
+ appliedModel = model || '';
912
+ }
913
+ return session;
914
+ }
915
+ if (model && model !== appliedModel) return switchModel(model, relayMode);
916
+ return session;
917
+ }
918
+
919
+ async function stopRuntime(reason) {
920
+ stopLifecycleTimer();
921
+ try { detachRuntimeExit(); } catch { /* the observer is best-effort */ }
922
+ detachRuntimeExit = () => {};
923
+ const closingSession = session;
924
+ const closingClient = client;
925
+ session = null;
926
+ client = null;
927
+ appliedModel = '';
928
+ // Detached shells are children of the runtime process, so stopping it ends
929
+ // them: the tracked set is state about a process that no longer exists and
930
+ // must not pin the next one. The replay gate is reset for the same reason —
931
+ // the next connection resumes and arms its own window.
932
+ backgroundShells.reset();
933
+ replayGate.reset();
934
+ continuationDueSince = 0;
935
+ pendingActivities = [];
936
+ if (!closingSession && !closingClient) return;
937
+ dbg(`stopping the copilot runtime (${reason})`);
938
+ try { await closingSession?.disconnect?.(); } catch (error) {
939
+ dbg('session disconnect failed', error?.message || String(error));
940
+ }
941
+ try { await closingClient?.stop?.(); } catch (error) {
942
+ dbg('client stop failed', error?.message || String(error));
943
+ }
944
+ }
945
+
946
+ // -------------------------------------------------------------- lifecycle --
947
+
948
+ /**
949
+ * Whether background work is holding the runtime open.
950
+ *
951
+ * `stopRuntime` ends the CLI runtime process, and a detached shell is a child
952
+ * of it — so stopping while one is live does not merely postpone the
953
+ * continuation, it KILLS the command. The live capture's 1-minute timer
954
+ * survived only because it was shorter than the 10-minute idle window; a
955
+ * 15-minute one would have been silently destroyed.
956
+ *
957
+ * Two holds, both bounded:
958
+ *
959
+ * - a settled shell whose continuation has not opened yet
960
+ * (`continuationGraceMs`), because the runtime is about to re-invoke the
961
+ * model and closing it in that gap loses the reply;
962
+ * - live shells, until `getBackgroundTaskTimeoutMs()` (0 = no limit). On
963
+ * expiry the shells are FORGOTTEN rather than stopped: unlike the Claude
964
+ * SDK, runtime 1.0.82 exposes no way to stop a shell from the host side
965
+ * (`stop_bash` is a tool the *model* calls), so the honest choice is to
966
+ * stop pretending they will report back and let the runtime — and with it
967
+ * the shells — go. Logged, because it means a command was cut short.
968
+ */
969
+ function backgroundWorkHoldsRuntime() {
970
+ const now = Date.now();
971
+ if (continuationDueSince) {
972
+ if (now - continuationDueSince < continuationGraceMs) return true;
973
+ // The runtime notified and then decided it had nothing to say. Stop
974
+ // pinning; this is the Claude worker's `notificationGraceMs` backstop.
975
+ dbg('continuation grace expired with no continuation turn');
976
+ continuationDueSince = 0;
977
+ }
978
+ if (!backgroundShells.size()) return false;
979
+ const capMs = Number(getBackgroundTaskTimeoutMs()) || 0;
980
+ for (const shell of backgroundShells.expireOlderThan(capMs)) {
981
+ dbg(
982
+ 'background shell cap reached; it no longer holds the runtime open',
983
+ shell.shellId,
984
+ shell.description || '(no description)',
985
+ );
986
+ }
987
+ return backgroundShells.size() > 0;
988
+ }
989
+
990
+ function evaluateLifecycle() {
991
+ // A pending question card means a human is mid-answer; tearing the runtime
992
+ // down under them would discard the session the answer belongs to.
993
+ if (disposed || activeTurn || pendingHumanRequests > 0 || !client) return;
994
+ if (!(idleShutdownMs > 0)) return;
995
+ // Evaluated before the idle clock so the caps still expire on a runtime
996
+ // that has been quiet far longer than the idle window.
997
+ if (backgroundWorkHoldsRuntime()) return;
998
+ if (Date.now() - lastActivityAt < idleShutdownMs) return;
999
+ void stopRuntime('idle').catch(() => {});
1000
+ }
1001
+
1002
+ function startLifecycleTimer() {
1003
+ if (lifecycleTimer || !(idleShutdownMs > 0)) return;
1004
+ lifecycleTimer = setInterval(evaluateLifecycle, lifecyclePollMs);
1005
+ lifecycleTimer.unref?.();
1006
+ }
1007
+
1008
+ function stopLifecycleTimer() {
1009
+ if (!lifecycleTimer) return;
1010
+ clearInterval(lifecycleTimer);
1011
+ lifecycleTimer = null;
1012
+ }
1013
+
1014
+ // ------------------------------------------------------------------- turn --
1015
+
1016
+ function resolvePerTurnModel(message) {
1017
+ const requested = String(message?.model || '').trim();
1018
+ if (requested && requested.toLowerCase() !== 'auto') return requested;
1019
+ return String(message?.providerModel || '').trim() || defaultModel;
1020
+ }
1021
+
1022
+ function createTurn(message, { kind = 'delivered' } = {}) {
1023
+ const continuation = kind === 'continuation';
1024
+ const turn = {
1025
+ // 'delivered' (a queue row arrived) | 'continuation' (the runtime started
1026
+ // work by itself and this worker minted a synthetic row for it).
1027
+ kind,
1028
+ message,
1029
+ normalizer: createNormalizerImpl(),
1030
+ state: { lastStreamedText: '' },
1031
+ result: null,
1032
+ settled: false,
1033
+ aborted: false,
1034
+ stallTimer: null,
1035
+ planBoardPosted: false,
1036
+ // Set when a mutating tool was actually approved and run this turn.
1037
+ acted: false,
1038
+ // A delivered row exists before the turn does, so its actions publish
1039
+ // straight away. A continuation's row is created asynchronously, so its
1040
+ // actions buffer here until it has an id.
1041
+ registered: !continuation,
1042
+ bufferedActions: [],
1043
+ // Set when the synthetic row could not be created; the turn's relay
1044
+ // output is dropped (it still lands in the runtime's own transcript)
1045
+ // rather than failing the worker.
1046
+ discarded: false,
1047
+ controlState: null,
1048
+ // How many prompts have been SENT into this interaction. The runtime
1049
+ // consumes queued prompts in order, and opens one `user.message` segment
1050
+ // per prompt as it picks each up, so send order IS segment order.
1051
+ //
1052
+ // Deliberately not derived from the normalizer's live segment count: at
1053
+ // the moment a steering send happens the runtime may not have opened the
1054
+ // previous prompt's segment yet, and the steered row would then be
1055
+ // attributed the PREVIOUS prompt's answer.
1056
+ //
1057
+ // Starts at 1, not 0: this turn's own prompt is always its first, and
1058
+ // counting it here rather than after `send()` resolves closes the race
1059
+ // where a steering delivery lands between the two. A continuation sent no
1060
+ // prompt at all, so it starts at 0.
1061
+ promptsSent: continuation ? 0 : 1,
1062
+ // The segment this turn's OWN reply lands in. For a delivered turn that
1063
+ // is where its prompt is picked up — non-zero when a previous interaction
1064
+ // left prompts queued inside the runtime, which are consumed first. A
1065
+ // continuation has no `user.message` of its own, so the normalizer's
1066
+ // implicit first segment is its own.
1067
+ baseSegment: continuation ? 0 : carriedPrompts,
1068
+ // The segment the NEXT prompt steered into this interaction will be
1069
+ // answered in. Tracked explicitly rather than derived, because the two
1070
+ // kinds differ: a delivered turn's own prompt occupies `baseSegment`, so
1071
+ // the next is `baseSegment + 1`; a continuation occupies segment 0
1072
+ // without having sent anything, so the first steered prompt opens
1073
+ // segment 1. Getting this wrong cross-publishes the continuation's reply
1074
+ // into the user's row.
1075
+ nextSegmentIndex: continuation ? 1 : carriedPrompts + 1,
1076
+ // The lowest segment index any prompt SENT by this turn can occupy — the
1077
+ // floor for the carried-prompt arithmetic when the interaction ends.
1078
+ firstSentSegment: continuation ? 1 : carriedPrompts,
1079
+ // Cancels any relay question card this turn is blocked on, so an aborted
1080
+ // turn does not leave a human answering into the void.
1081
+ abortController: new AbortController(),
1082
+ // Queue rows delivered mid-turn and steered into this interaction. Each
1083
+ // one is owed a response by THIS turn — the runtime answers them all
1084
+ // under a single `session.idle`, so nothing else will settle them.
1085
+ steeredRows: [],
1086
+ };
1087
+ turn.done = new Promise((resolve, reject) => {
1088
+ turn.resolveDone = resolve;
1089
+ turn.rejectDone = reject;
1090
+ });
1091
+ // The stall watchdog and the runtime-exit observer can reject `turn.done`
1092
+ // before `runTurn` reaches its `await` — an unhandled rejection that the
1093
+ // worker crash guard would escalate into a whole-worker failure. This
1094
+ // handler exists only to mark the promise as handled; the real await path
1095
+ // still sees the rejection.
1096
+ turn.done.catch(() => {});
1097
+ turn.settle = () => {
1098
+ if (turn.settled) return;
1099
+ turn.settled = true;
1100
+ turn.disarmStall();
1101
+ turn.resolveDone();
1102
+ };
1103
+ turn.fail = (error) => {
1104
+ if (turn.settled) return;
1105
+ turn.settled = true;
1106
+ turn.disarmStall();
1107
+ turn.rejectDone(error);
1108
+ };
1109
+ turn.disarmStall = () => {
1110
+ if (!turn.stallTimer) return;
1111
+ clearTimeout(turn.stallTimer);
1112
+ turn.stallTimer = null;
1113
+ };
1114
+ turn.armStall = () => {
1115
+ if (!(turnStallTimeoutMs > 0) || turn.settled) return;
1116
+ turn.disarmStall();
1117
+ turn.stallTimer = setTimeout(() => {
1118
+ // A human staring at a question card is not a stalled runtime. Re-arm
1119
+ // rather than fail: the card has its own (much longer) timeout, and
1120
+ // failing the row here would settle it while the answer is still coming.
1121
+ if (pendingHumanRequests > 0) {
1122
+ turn.armStall();
1123
+ return;
1124
+ }
1125
+ turn.fail(new Error(
1126
+ `copilot worker watchdog: the runtime produced no events for ${Math.round(turnStallTimeoutMs / 1000)}s; `
1127
+ + 'the row is failed — resend the message to retry',
1128
+ ));
1129
+ }, turnStallTimeoutMs);
1130
+ turn.stallTimer.unref?.();
1131
+ };
1132
+ return turn;
1133
+ }
1134
+
1135
+ async function runTurn(message) {
1136
+ const model = resolvePerTurnModel(message);
1137
+ const relayMode = message?.relayMode || 'agent';
1138
+ lastRelayMode = relayMode;
1139
+ const turn = createTurn(message);
1140
+ // Set before the session is touched: the heartbeat's owner-recovery guard
1141
+ // reads the active ids, so a cold-start delivery must already own its row.
1142
+ activeTurn = turn;
1143
+
1144
+ const controlState = controlPoller?.start?.({
1145
+ queueMessageId: message.id,
1146
+ onAbortTurn: async () => {
1147
+ turn.aborted = true;
1148
+ // While `ensureSession` is still connecting there is no session to
1149
+ // abort and this would be a silent no-op; `runTurn` re-checks
1150
+ // `turn.aborted` once the session exists and settles there instead.
1151
+ if (!session) return;
1152
+ // The runtime answers an abort with `abort` → `agent.interrupted` →
1153
+ // `assistant.turn_end` → `session.idle{aborted:true}`, which settles
1154
+ // the turn through the normal terminator; this only asks for it.
1155
+ await session.abort?.();
1156
+ },
1157
+ });
1158
+
1159
+ try {
1160
+ await ensureSession(model, relayMode);
1161
+ if (turn.aborted) {
1162
+ // The abort landed while the session was still being built. Nothing
1163
+ // was sent, so there is no runtime turn to interrupt — ask anyway (a
1164
+ // queued prompt from a previous delivery could still be running) and
1165
+ // settle locally rather than sending a prompt the user just cancelled.
1166
+ dbg('turn aborted before send', message.id);
1167
+ try { await session?.abort?.(); } catch (error) {
1168
+ dbg('abort during session setup failed', error?.message || String(error));
1169
+ }
1170
+ turn.settle();
1171
+ } else {
1172
+ turn.armStall();
1173
+ const sendMode = String(resolveSendModeImpl(message) || '').trim();
1174
+ const { prompt: body, attachments } = buildMessageOptionsImpl(message);
1175
+ // Relay mode marker + (on a mode change) the standing mode instructions,
1176
+ // the relay tool guidance and the live preview-lane block.
1177
+ const prefix = await buildRelayContextPrefix(message).catch(() => '');
1178
+ const prompt = withRelayContext(prefix, body);
1179
+ // `mode` and `attachments` are FIELDS of the single MessageOptions
1180
+ // argument — `send()` takes no second parameter, so passing options
1181
+ // positionally drops them silently.
1182
+ await session.send({
1183
+ prompt,
1184
+ ...(attachments?.length ? { attachments } : {}),
1185
+ ...(sendMode ? { mode: sendMode } : {}),
1186
+ agentMode: copilotAgentModeForRelayMode(relayMode),
1187
+ });
1188
+ // `send()` resolves once the runtime accepted the prompt — it is NOT
1189
+ // the turn's completion; the event stream is.
1190
+ await turn.done;
1191
+ }
1192
+ } finally {
1193
+ controlPoller?.stop?.(controlState);
1194
+ await quiesceTurn(turn);
1195
+ }
1196
+
1197
+ return finishTurn(turn, model);
1198
+ }
1199
+
1200
+ /**
1201
+ * Everything that must happen between "the turn stopped producing events" and
1202
+ * "its state may be read": stop the watchdog, release anything blocked on a
1203
+ * question card, drain in-flight relay POSTs, and close subagent runs the
1204
+ * normalizer never got to close.
1205
+ */
1206
+ async function quiesceTurn(turn) {
1207
+ turn.disarmStall();
1208
+ // The card's answer can no longer reach the runtime.
1209
+ turn.abortController.abort();
1210
+ // Drain in-flight dispatches before the turn's state is read, so a stream
1211
+ // POST cannot land after the response.
1212
+ await dispatchChain.catch(() => {});
1213
+ // Any subagent still open at this point never will be. The normalizer
1214
+ // closes strays when it produces a terminal result; these are the paths
1215
+ // that never produced one.
1216
+ await closeStraySubagentRuns(turn).catch(() => {});
1217
+ }
1218
+
1219
+ /**
1220
+ * Publish a finished turn's outcome onto its queue row (and every row steered
1221
+ * into it).
1222
+ *
1223
+ * Shared by both turn kinds. A continuation reaches it through
1224
+ * `driveContinuation` instead of `runTurn`, but the outcomes are identical:
1225
+ * the runtime does not distinguish a turn it started from one it was asked
1226
+ * for, so neither does the reporting.
1227
+ */
1228
+ async function finishTurn(turn, model) {
1229
+ const { message } = turn;
1230
+ const result = turn.result;
1231
+ const responseModel = result?.model || turn.normalizer.model || model || null;
1232
+ // Capture only. The POST fires from `handlePendingPayload`'s `finally`,
1233
+ // after this row has been published.
1234
+ captureTurnUsage(message, result);
1235
+
1236
+ // Whatever this interaction did not get to stays queued in the runtime and
1237
+ // shifts the NEXT interaction's segments: the prompts this turn sent occupy
1238
+ // `[firstSentSegment, nextSegmentIndex)`, and the ones the runtime never
1239
+ // opened a segment for are still in its pending queue.
1240
+ //
1241
+ // The `max` against `firstSentSegment` is what makes this correct for a
1242
+ // continuation, which occupies segment 0 without having sent anything: a
1243
+ // continuation that opened no segment at all (an empty self-initiated turn)
1244
+ // would otherwise report one carried prompt and shift the next real turn's
1245
+ // answer by one.
1246
+ const segmentsOpened = turn.normalizer.promptCount();
1247
+ carriedPrompts = Math.max(0, turn.nextSegmentIndex - Math.max(turn.firstSentSegment, segmentsOpened));
1248
+
1249
+ // This row's own reply. Falls back to the whole composed text when the
1250
+ // runtime opened no segment at all (an empty or immediately-failed turn),
1251
+ // which is strictly better than publishing nothing.
1252
+ const ownText = String(
1253
+ turn.normalizer.segmentText(turn.baseSegment) || result?.text || turn.state.lastStreamedText || '',
1254
+ ).trim();
1255
+
1256
+ // A user-initiated abort publishes the partial text and nothing else: the
1257
+ // queue row's fate belongs to the server-side abort control, exactly as in
1258
+ // the Claude and Cursor workers. Publishing a response here would
1259
+ // double-settle the row.
1260
+ if (turn.aborted) {
1261
+ dbg('turn aborted', message.id);
1262
+ // This row's segment only: the steered rows publish their own, and
1263
+ // publishing the composed text here would show their replies twice.
1264
+ await publishFinalStream(message, ownText);
1265
+ // A steered row is NOT covered by the abort control (which knows only
1266
+ // about the row the user aborted), so it still has to be settled here or
1267
+ // it holds `processing` forever behind a renewing lease.
1268
+ await settleSteeredRows(turn, result, responseModel);
1269
+ return true;
1270
+ }
1271
+
1272
+ // The runtime aborted on its own (`result.aborted` with no relay-side
1273
+ // abort control in flight). Nothing server-side is waiting to settle this
1274
+ // row, so returning here would leave it pending until the delivery
1275
+ // watchdog fails it with a misleading "Relay timeout" — the row has to be
1276
+ // settled with a record that says what actually happened.
1277
+ if (result?.aborted) {
1278
+ dbg('turn interrupted by the runtime', message.id);
1279
+ const text = ownText ? `${ownText}\n\n${RUNTIME_INTERRUPTED_NOTE}` : RUNTIME_INTERRUPTED_NOTE;
1280
+ await publishFinalStream(message, text);
1281
+ await publishResponse(message, { text, model: responseModel });
1282
+ await settleSteeredRows(turn, result, responseModel);
1283
+ return true;
1284
+ }
1285
+
1286
+ if (result?.isError) {
1287
+ const classified = classifyCopilotSessionError(result.errorData || { message: result.errorMessage });
1288
+ dbg('turn failed', message.id, classified.stableCode);
1289
+ await publishFinalStream(message, turn.state.lastStreamedText);
1290
+ await publishResponse(message, {
1291
+ text: classified.text,
1292
+ model: responseModel,
1293
+ terminalError: terminalErrorRecord(message, classified),
1294
+ });
1295
+ // The steered rows failed with it — they were being answered by the same
1296
+ // interaction. They get the same terminal record so each row says why.
1297
+ await settleSteeredRows(turn, result, responseModel, classified);
1298
+ return true;
1299
+ }
1300
+
1301
+ // The turn produced a plan but never called exit-plan-mode (or the runtime
1302
+ // build has no such hook). Same text-shape fallback the siblings use, and
1303
+ // it must go out BEFORE the response: `/api/relay-board` 409s once the
1304
+ // queue row leaves `processing`.
1305
+ if (shouldPostPlanBoard({
1306
+ relayMode: message.relayMode,
1307
+ finalText: ownText,
1308
+ alreadyPosted: turn.planBoardPosted,
1309
+ acted: turn.acted === true,
1310
+ })) {
1311
+ await publishPlanBoard(turn, ownText, 'plan-mode-fallback');
1312
+ }
1313
+
1314
+ // A terminal, non-error turn with no prose is COMPLETE, not a failed
1315
+ // delivery — requeuing re-runs deterministically empty work until the
1316
+ // retry cap fails the row with a misleading "Relay timeout".
1317
+ const publishedText = ownText || EMPTY_TURN_COMPLETION_NOTE;
1318
+ await publishFinalStream(message, publishedText);
1319
+ await publishResponse(message, { text: publishedText, model: responseModel });
1320
+ await settleSteeredRows(turn, result, responseModel);
1321
+ return true;
1322
+ }
1323
+
1324
+ // ----------------------------------------------------------- continuation --
1325
+
1326
+ /**
1327
+ * The runtime started a turn nobody asked for. Give it a relay row.
1328
+ *
1329
+ * Called synchronously from `routeEvent`, so `activeTurn` is set before the
1330
+ * triggering event is dispatched — which is what stops a second opener in the
1331
+ * same batch from minting a second row for the same turn, and what makes idle
1332
+ * shutdown and the heartbeat see the work immediately.
1333
+ *
1334
+ * The row itself is created asynchronously (`POST /api/continuation-turn`);
1335
+ * everything the turn produces meanwhile buffers on the turn and flushes in
1336
+ * order once the row has an id.
1337
+ */
1338
+ function openContinuationTurn() {
1339
+ const turn = createTurn({
1340
+ id: null,
1341
+ conversationId: sdkSessionId,
1342
+ // A self-initiated turn has no delivery to read a mode off, so it runs in
1343
+ // the mode the conversation was last driven in — the permission handler
1344
+ // and the plan-board gating both read the live turn's mode.
1345
+ relayMode: lastRelayMode,
1346
+ model: '',
1347
+ }, { kind: 'continuation' });
1348
+ activeTurn = turn;
1349
+ // The gap this pin covers has closed.
1350
+ continuationDueSince = 0;
1351
+ turn.armStall();
1352
+ // Lines produced between turns (a settled shell's notification) belong to
1353
+ // the turn they triggered.
1354
+ if (pendingActivities.length) {
1355
+ for (const text of pendingActivities.splice(0)) {
1356
+ turn.bufferedActions.push({ channel: 'activity', payload: { text, subagentRunId: null } });
1357
+ }
1358
+ }
1359
+ dbg('opening a continuation turn for runtime-initiated work');
1360
+ // Both are fire-and-forget by design (the SDK's event callback is
1361
+ // synchronous and cannot await a turn), so both must swallow: an unhandled
1362
+ // rejection here would reach the worker crash guard and take the whole
1363
+ // process down over one lost continuation.
1364
+ registerContinuationRow(turn).catch((error) => {
1365
+ dbg('continuation registration threw', error?.message || String(error));
1366
+ if (turn.message.id) {
1367
+ // The row was created and only the bookkeeping after it failed. Release
1368
+ // the buffer to the drive path rather than throwing away a turn that
1369
+ // has somewhere to go.
1370
+ turn.registered = true;
1371
+ return;
1372
+ }
1373
+ turn.discarded = true;
1374
+ turn.bufferedActions = [];
1375
+ });
1376
+ driveContinuation(turn).catch((error) => {
1377
+ dbg('continuation driver threw', error?.message || String(error));
1378
+ });
1379
+ return turn;
1380
+ }
1381
+
1382
+ /**
1383
+ * Create the synthetic queue row and release the turn's buffered output.
1384
+ *
1385
+ * Retries on any response that produced no message id — a truthy but empty
1386
+ * body must not end the loop early. Giving up discards the turn's relay
1387
+ * output (it still lands in the runtime's own transcript) rather than failing
1388
+ * the worker: a continuation nobody can see is a lost message, not a broken
1389
+ * session.
1390
+ */
1391
+ async function registerContinuationRow(turn) {
1392
+ let response = null;
1393
+ for (let attempt = 0; attempt < 3 && !response?.messageId; attempt += 1) {
1394
+ if (turn.discarded) return;
1395
+ response = await api('POST', '/api/continuation-turn', {
1396
+ conversationId: sdkSessionId,
1397
+ sdkSessionId,
1398
+ relayMode: turn.message.relayMode,
1399
+ trigger: CONTINUATION_TRIGGER,
1400
+ }).catch((error) => {
1401
+ dbg('continuation turn registration failed', error?.message || String(error));
1402
+ return null;
1403
+ });
1404
+ if (!response?.messageId) {
1405
+ await new Promise((resolve) => { setTimeout(resolve, continuationRetryDelayMs); });
1406
+ }
1407
+ }
1408
+ if (!response?.messageId) {
1409
+ turn.discarded = true;
1410
+ turn.bufferedActions = [];
1411
+ dbg('continuation turn discarded (no relay message id)');
1412
+ return;
1413
+ }
1414
+ turn.message.id = String(response.messageId);
1415
+ // The route reports which conversation the synthetic row landed on;
1416
+ // trusting it beats assuming worker session id === conversation id.
1417
+ const conversationId = String(response.conversationId || '').trim();
1418
+ if (conversationId) turn.message.conversationId = conversationId;
1419
+ // Only now is there a row to abort, so this is where the control poller can
1420
+ // start.
1421
+ turn.controlState = controlPoller?.start?.({
1422
+ queueMessageId: turn.message.id,
1423
+ onAbortTurn: async () => {
1424
+ turn.aborted = true;
1425
+ if (!session) return;
1426
+ await session.abort?.();
1427
+ },
1428
+ }) || null;
1429
+ await flushBufferedActions(turn);
1430
+ turn.registered = true;
1431
+ }
1432
+
1433
+ /** Resolve once the continuation's row exists, or its registration gave up. */
1434
+ async function awaitContinuationRow(turn, timeoutMs = continuationRegistrationTimeoutMs) {
1435
+ const deadline = Date.now() + timeoutMs;
1436
+ while (!turn.registered && !turn.discarded && Date.now() < deadline) {
1437
+ await new Promise((resolve) => { setTimeout(resolve, 25); });
1438
+ }
1439
+ }
1440
+
1441
+ /**
1442
+ * Run a continuation to its terminator and publish it, mirroring
1443
+ * `handlePendingPayload`'s outer shape.
1444
+ *
1445
+ * `activeTurn` is cleared only after every publish has landed, exactly as for
1446
+ * a delivered turn: a heartbeat firing inside the publish window with no
1447
+ * active ids would tell the relay this worker owns nothing, and the
1448
+ * still-`processing` synthetic row would be recovered underneath it.
1449
+ */
1450
+ async function driveContinuation(turn) {
1451
+ let failure = null;
1452
+ try {
1453
+ await turn.done;
1454
+ } catch (error) {
1455
+ failure = error;
1456
+ }
1457
+ try {
1458
+ await quiesceTurn(turn);
1459
+ // Nothing can be published before the row exists. The buffer is drained
1460
+ // by the registration itself; this only covers actions that arrived
1461
+ // during the drain.
1462
+ await awaitContinuationRow(turn);
1463
+ // The invariant is the id, not the flag: registration can also give up by
1464
+ // throwing, or by taking longer than `awaitContinuationRow` waits, and
1465
+ // publishing against `messageId: null` would attribute the whole turn to
1466
+ // nothing at all.
1467
+ if (!turn.message.id) {
1468
+ turn.discarded = true;
1469
+ turn.bufferedActions = [];
1470
+ dbg('continuation output dropped (no relay row)');
1471
+ // The steering path is still waiting on any row it handed us, and it
1472
+ // has no row of its own to fall back to.
1473
+ await settleSteeredRows(turn, null, null, classifyCopilotTurnException(
1474
+ failure || new Error('the relay refused a continuation row for this turn'),
1475
+ )).catch(() => {});
1476
+ return;
1477
+ }
1478
+ await flushBufferedActions(turn);
1479
+ if (failure) {
1480
+ const classified = classifyCopilotTurnException(failure);
1481
+ dbg('continuation turn failed', turn.message.id, classified.detail);
1482
+ await publishResponse(turn.message, {
1483
+ text: classified.text,
1484
+ model: null,
1485
+ terminalError: terminalErrorRecord(turn.message, classified),
1486
+ });
1487
+ await settleSteeredRows(turn, null, null, classified).catch(() => {});
1488
+ return;
1489
+ }
1490
+ await finishTurn(turn, '');
1491
+ } catch (error) {
1492
+ dbg('continuation publish failed', error?.message || String(error));
1493
+ } finally {
1494
+ controlPoller?.stop?.(turn.controlState);
1495
+ turn.controlState = null;
1496
+ if (activeTurn === turn) activeTurn = null;
1497
+ touch();
1498
+ // A continuation spends real quota (the live capture burned a premium
1499
+ // request on `read_bash` + the reply), so its numbers ride the same
1500
+ // fire-and-forget ingest as a delivered turn's.
1501
+ postTurnUsage();
1502
+ }
1503
+ }
1504
+
1505
+ /**
1506
+ * Settle every queue row that was steered into this interaction.
1507
+ *
1508
+ * The runtime answers the original prompt AND every prompt queued behind it
1509
+ * under ONE `session.idle` (live-verified — see the plan doc §5), so no
1510
+ * second turn will ever settle these rows. Each one is answered with the text
1511
+ * of ITS OWN prompt segment, which the normalizer separates using the
1512
+ * `user.message` events that mark where the runtime picked each prompt up.
1513
+ *
1514
+ * A row whose prompt the runtime accepted but never started work on has no
1515
+ * segment. It is NOT requeued: the prompt is still sitting in the runtime's
1516
+ * pending queue and will be answered at the start of the next turn, so
1517
+ * redelivering it would run the same prompt twice. It gets a note instead —
1518
+ * the same trade the Claude worker makes for a handed-off context.
1519
+ */
1520
+ async function settleSteeredRows(turn, result, responseModel, classified = null) {
1521
+ if (!turn.steeredRows.length) return;
1522
+ for (const steered of turn.steeredRows) {
1523
+ const { message: steeredMessage, segmentIndex } = steered;
1524
+ let text = String(turn.normalizer.segmentText(segmentIndex) || '').trim();
1525
+ if (classified) {
1526
+ text = classified.text;
1527
+ } else if (!text) {
1528
+ text = STEERED_ROW_MERGED_NOTE;
1529
+ }
1530
+ await publishFinalStream(steeredMessage, text);
1531
+ await publishResponse(steeredMessage, {
1532
+ text,
1533
+ model: responseModel,
1534
+ ...(classified ? { terminalError: terminalErrorRecord(steeredMessage, classified) } : {}),
1535
+ });
1536
+ steered.settle?.();
1537
+ }
1538
+ turn.steeredRows.length = 0;
1539
+ }
1540
+
1541
+ /**
1542
+ * A delivery arrived while a turn was already running.
1543
+ *
1544
+ * The relay's worker socket is single-flight — it will not deliver a second
1545
+ * row until `onDeliver` resolves — so this is not the normal path. It is
1546
+ * reachable on a socket reconnect that redelivers, and it is the path a
1547
+ * future relay change would take, so it steers rather than corrupting the
1548
+ * turn: the prompt is queued into the SAME interaction (which is all
1549
+ * `mode: "enqueue"` can do — it cannot interrupt an in-flight model call),
1550
+ * and the row is adopted so the interaction's terminator settles it too.
1551
+ *
1552
+ * The row is registered BEFORE the send so a failure between the two cannot
1553
+ * leave a row nobody owns.
1554
+ */
1555
+ async function steerIntoActiveTurn(turn, message) {
1556
+ const { prompt: body, attachments } = buildMessageOptionsImpl(message);
1557
+ // A real relay round trip on the first turn of a mode — long enough for the
1558
+ // turn to finish underneath us.
1559
+ const prefix = await buildRelayContextPrefix(message).catch(() => '');
1560
+ const prompt = withRelayContext(prefix, body);
1561
+ // Re-checked AFTER the awaits and before anything is sent or registered.
1562
+ // `settleSteeredRows` has already run if the turn settled during them, so a
1563
+ // row pushed now would never be settled and its caller would wait forever —
1564
+ // wedging the single-flight delivery socket. Nothing has been sent yet, so
1565
+ // handing the row back to the normal path is free.
1566
+ if (turn.settled) {
1567
+ dbg('turn settled while steering was preparing; running it as a fresh turn', message.id);
1568
+ return NOT_STEERED;
1569
+ }
1570
+ // Prompts are consumed in the order they were sent, and each opens its own
1571
+ // `user.message` segment as it is picked up, so this prompt's send position
1572
+ // (after any prompts carried over from a previous interaction, and after
1573
+ // whatever the interaction itself occupies) is its segment index.
1574
+ const segmentIndex = turn.nextSegmentIndex;
1575
+ turn.nextSegmentIndex += 1;
1576
+ turn.promptsSent += 1;
1577
+ const steered = { message, segmentIndex };
1578
+ steered.done = new Promise((resolve) => { steered.settle = resolve; });
1579
+ turn.steeredRows.push(steered);
1580
+ dbg('steering a mid-turn delivery into the running turn', message.id, `segment=${segmentIndex}`);
1581
+ try {
1582
+ await session.send({
1583
+ prompt,
1584
+ ...(attachments?.length ? { attachments } : {}),
1585
+ mode: 'enqueue',
1586
+ agentMode: copilotAgentModeForRelayMode(message?.relayMode || 'agent'),
1587
+ });
1588
+ } catch (error) {
1589
+ // The prompt never reached the runtime, so nothing will answer it and the
1590
+ // row is safe to requeue — unlike an accepted one.
1591
+ turn.steeredRows = turn.steeredRows.filter((entry) => entry !== steered);
1592
+ dbg('steering send failed, requeuing the row', message.id, error?.message || String(error));
1593
+ await api('POST', '/api/requeue', { messageId: message.id }).catch(() => {});
1594
+ return true;
1595
+ }
1596
+ // Resolves when the interaction settles this row in `settleSteeredRows`.
1597
+ await steered.done;
1598
+ return true;
1599
+ }
1600
+
1601
+ async function handlePendingPayload(pending) {
1602
+ const message = pending?.message || null;
1603
+ if (!message) return false;
1604
+ // A delivery that lands while a turn is running is steered into it rather
1605
+ // than starting a second one: the runtime has a single conversation and a
1606
+ // concurrent `send` would interleave into the same interaction anyway —
1607
+ // this way the row is owned and settled instead of orphaned.
1608
+ //
1609
+ // Deliberately OUTSIDE the try/finally below: that `finally` clears
1610
+ // `activeTurn`, and a steered call returns while the turn it was steered
1611
+ // into is still publishing. Clearing there would tell the heartbeat this
1612
+ // worker owns nothing and the relay would recover the live row.
1613
+ if (activeTurn && !activeTurn.settled && session) {
1614
+ const turn = activeTurn;
1615
+ try {
1616
+ const outcome = await steerIntoActiveTurn(turn, message);
1617
+ if (outcome !== NOT_STEERED) return outcome;
1618
+ } catch (error) {
1619
+ dbg('steering failed', message.id, error?.message || String(error));
1620
+ const classified = classifyCopilotTurnException(error);
1621
+ await publishResponse(message, {
1622
+ text: classified.text,
1623
+ model: null,
1624
+ terminalError: terminalErrorRecord(message, classified),
1625
+ });
1626
+ return true;
1627
+ }
1628
+ }
1629
+ try {
1630
+ return await runTurn(message);
1631
+ } catch (error) {
1632
+ const classified = classifyCopilotTurnException(error);
1633
+ dbg('copilot turn failed', message.id, classified.detail);
1634
+ // A failure that killed the session (or came from starting it) leaves a
1635
+ // handle nothing else can use; drop it so the next delivery rebuilds and
1636
+ // resumes rather than sending into a dead runtime.
1637
+ await stopRuntime('turn-failure').catch(() => {});
1638
+ await publishResponse(message, {
1639
+ text: classified.text,
1640
+ model: null,
1641
+ terminalError: terminalErrorRecord(message, classified),
1642
+ });
1643
+ // Rows steered into the turn that just threw are owed a response too —
1644
+ // and, more urgently, `steerIntoActiveTurn` is still awaiting their
1645
+ // settle. Skipping this would wedge that caller (and its queue row)
1646
+ // forever behind a heartbeat that keeps renewing the lease.
1647
+ if (activeTurn) {
1648
+ await settleSteeredRows(activeTurn, null, null, classified).catch(() => {});
1649
+ await closeStraySubagentRuns(activeTurn).catch(() => {});
1650
+ }
1651
+ return true;
1652
+ } finally {
1653
+ // Cleared only once every publish for this row has landed. A heartbeat
1654
+ // that fired during the publish window with no active ids would tell the
1655
+ // relay this worker owns nothing, and the still-processing row would be
1656
+ // recovered (`owner-heartbeat-idle`) and re-delivered — a duplicate
1657
+ // execution racing the response that was already on its way.
1658
+ activeTurn = null;
1659
+ touch();
1660
+ // Every path through the turn — published, failed, aborted, threw — has
1661
+ // finished by here, which is the only safe place for the ingest: it is
1662
+ // advisory, it is not awaited, and it must never be able to delay a reply
1663
+ // that is already written.
1664
+ postTurnUsage();
1665
+ }
1666
+ }
1667
+
1668
+ // --------------------------------------------------------------- teardown --
1669
+
1670
+ function getActiveQueueMessageId() {
1671
+ return activeTurn ? String(activeTurn.message?.id || '') : '';
1672
+ }
1673
+
1674
+ /**
1675
+ * Every row this worker owns — the turn's own, plus any steered into it.
1676
+ *
1677
+ * Both the heartbeat (lease renewal) and the crash guard (requeue-on-exit)
1678
+ * read this. A steered row missing from it would be recovered mid-flight as
1679
+ * `owner-heartbeat-mismatch` and re-delivered while the runtime was still
1680
+ * answering it.
1681
+ */
1682
+ function getActiveQueueMessageIds() {
1683
+ if (!activeTurn) return [];
1684
+ const ids = [];
1685
+ const primary = String(activeTurn.message?.id || '');
1686
+ if (primary) ids.push(primary);
1687
+ for (const steered of activeTurn.steeredRows || []) {
1688
+ const id = String(steered.message?.id || '');
1689
+ if (id && !ids.includes(id)) ids.push(id);
1690
+ }
1691
+ return ids;
1692
+ }
1693
+
1694
+ async function dispose() {
1695
+ disposed = true;
1696
+ stopLifecycleTimer();
1697
+ // A question card left `pending` would sit in the UI inviting an answer
1698
+ // that nothing is left to read. Time them out before the socket goes.
1699
+ await questionBridge.cancelPendingQuestions?.().catch?.(() => {});
1700
+ await stopRuntime('worker-shutdown');
1701
+ }
1702
+
1703
+ return {
1704
+ handlePendingPayload,
1705
+ getActiveQueueMessageId,
1706
+ getActiveQueueMessageIds,
1707
+ isTurnActive: () => !!activeTurn,
1708
+ dispose,
1709
+ // The turn's tokens/cost/TTFT, as posted to `/api/copilot-plan-usage`.
1710
+ getLastTurnUsage: () => lastTurnUsage,
1711
+ // The in-flight usage ingest. A test seam ONLY: the turn path deliberately
1712
+ // never awaits this, which is what keeps a slow relay from holding a
1713
+ // finished reply.
1714
+ whenUsagePosted: () => usagePostChain,
1715
+ // Test seams / observability.
1716
+ _getState: () => ({
1717
+ hasClient: !!client,
1718
+ hasSession: !!session,
1719
+ appliedModel,
1720
+ lastActivityAt,
1721
+ // 'delivered' | 'continuation' | '' — which kind of turn, if any, owns
1722
+ // the worker right now.
1723
+ activeTurnKind: activeTurn?.kind || '',
1724
+ backgroundShells: backgroundShells.live(),
1725
+ continuationDueSince,
1726
+ }),
1727
+ _evaluateLifecycle: evaluateLifecycle,
1728
+ };
1729
+ }