@openchambery/web 2.0.0-beta.1 → 2.0.0-beta.4

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 (287) hide show
  1. package/README.md +15 -11
  2. package/bin/cli-opencode.test.js +4 -4
  3. package/bin/cli.js +1 -1
  4. package/bin/lib/cli-startup.js +1 -1
  5. package/bin/lib/cli-startup.test.js +18 -4
  6. package/dist/assets/{AgentAvatar-CLWWNu_Q.js → AgentAvatar-Q1SmFRx2.js} +1 -1
  7. package/dist/assets/AssistantView-CwXkiCAd.js +3 -0
  8. package/dist/assets/{DiagramView-xuSJTnv7.js → DiagramView-BW92schO.js} +1 -1
  9. package/dist/assets/{FadeInOnReveal-Ov0D96d0.js → FadeInOnReveal-BZmwl-zE.js} +1 -1
  10. package/dist/assets/{Icon-DD-BuMfZ.js → Icon-AQPvxMiU.js} +1 -1
  11. package/dist/assets/{MarkdownRenderer-bc_p1H7l.js → MarkdownRenderer-Bv05iDZZ.js} +2 -2
  12. package/dist/assets/{MarkdownRendererImpl-SKi4oG0c.js → MarkdownRendererImpl-C81xqYib.js} +3 -3
  13. package/dist/assets/MarkstreamRendererImpl-CXUCQuKJ.js +3 -0
  14. package/dist/assets/{MobileDetailNavigation-Bnx59U_J.js → MobileDetailNavigation-BMgIu7zc.js} +1 -1
  15. package/dist/assets/{MobileShareBridge-BQlP4J-F.js → MobileShareBridge-BOXGNHmC.js} +2 -2
  16. package/dist/assets/MobileSurface-xuc_z0_G.js +2 -0
  17. package/dist/assets/{MultiRunWindow-ChZGob0v.js → MultiRunWindow-B5At2Z95.js} +1 -1
  18. package/dist/assets/{OnboardingScreen-DtQJxZG-.js → OnboardingScreen-bsPLgz4r.js} +1 -1
  19. package/dist/assets/OverlayScrollbar-4wp4migh.js +2 -0
  20. package/dist/assets/SettingsView-CJ6tcH5_.js +25 -0
  21. package/dist/assets/{SettingsWindow-DfnsFa8y.js → SettingsWindow-CMtX2bMl.js} +1 -1
  22. package/dist/assets/{TerminalView-Dh9MWCpS.js → TerminalView-Dvr-ysTS.js} +1 -1
  23. package/dist/assets/{ToolOutputDialog-BejmjrRk.js → ToolOutputDialog-uH1ZZDSS.js} +2 -2
  24. package/dist/assets/appThemeRegistry-DK7q2juD.js +1 -0
  25. package/dist/assets/{assistant-attachment-cache-C0aRnFkj.js → assistant-attachment-cache-DphJleHr.js} +1 -1
  26. package/dist/assets/{button-D-rATN7w.js → button-CcNJUxUd.js} +1 -1
  27. package/dist/assets/collapsible-ZmZ6rLWs.js +3 -0
  28. package/dist/assets/context-menu-B2LXJgXL.js +1 -0
  29. package/dist/assets/{desktopHosts-B1ixEylr.js → desktopHosts-C6HrP0jq.js} +1 -1
  30. package/dist/assets/{desktopRelayRestore-4JmFUw8r.js → desktopRelayRestore-DdVBp45U.js} +1 -1
  31. package/dist/assets/{diagram-DQf7Y_uR.js → diagram-CxTs9hCQ.js} +1 -1
  32. package/dist/assets/{dialog-bfbvR4VH.js → dialog-KMVL9QKF.js} +1 -1
  33. package/dist/assets/es-CN3E_zXj.js +15 -0
  34. package/dist/assets/{fileMentionTouchSelection-BvrNCroG.js → fileMentionTouchSelection-7RIwlpkB.js} +1 -1
  35. package/dist/assets/{fileQueries-DQc8EJUf.js → fileQueries-CjdBRy8y.js} +1 -1
  36. package/dist/assets/fr-9zZvQZD2.js +13 -0
  37. package/dist/assets/gitApi-yuWg9BN4.js +10 -0
  38. package/dist/assets/{imageSource-BlAJJUEI.js → imageSource-9vQBq3zp.js} +1 -1
  39. package/dist/assets/{input-8r6F_Fza.js → input-CrIvk3HZ.js} +1 -1
  40. package/dist/assets/insertionBoundaries-BiUCSkx-.js +1 -0
  41. package/dist/assets/ja-Dnjk_82N.js +15 -0
  42. package/dist/assets/ko-BxCEE99C.js +15 -0
  43. package/dist/assets/{main-CCE_4y5F.js → main-C9mIBo77.js} +2 -2
  44. package/dist/assets/main-DiL6w6KF.js +169 -0
  45. package/dist/assets/markdown-worker-bS5OgGO3.js +2 -0
  46. package/dist/assets/{markdownHeightCache-1Qo6y6IJ.js → markdownHeightCache-BmXVY_gc.js} +1 -1
  47. package/dist/assets/{markdownTheme-DOfGg-A7.js → markdownTheme-CKac3wKC.js} +1 -1
  48. package/dist/assets/mcpQueries-C7jknHV8.js +1 -0
  49. package/dist/assets/miniChat-CwtUaElU.js +2 -0
  50. package/dist/assets/mobile-DB-6--ws.js +2 -0
  51. package/dist/assets/multirun-TAsxFI8e.js +4 -0
  52. package/dist/assets/{persistence-Ce3AnpEr.js → persistence-56kq8Z4V.js} +2 -2
  53. package/dist/assets/pl-CNNsWnPM.js +15 -0
  54. package/dist/assets/platform-C13fMVEs.js +6 -0
  55. package/dist/assets/pt-BR-BZB2DJMp.js +15 -0
  56. package/dist/assets/{relay-image-stream-DM7atqJs.js → relay-image-stream-DrdVj99c.js} +1 -1
  57. package/dist/assets/renderElectronMiniChatApp-CGj0MNRU.js +2 -0
  58. package/dist/assets/renderMobileApp-CGMMqbsO.js +28 -0
  59. package/dist/assets/runtime-fetch-Fs9ZV2V8.js +1 -0
  60. package/dist/assets/{runtimeConfig-BMvqa5we.js → runtimeConfig-BEANkvOd.js} +1 -1
  61. package/dist/assets/runtimeEndpointReset-A_ZBUJFS.js +147 -0
  62. package/dist/assets/sessionChangeSummary-qOLrP9fw.js +2 -0
  63. package/dist/assets/{sessionLookup-gh-hVRIc.js → sessionLookup-34XFmP9C.js} +1 -1
  64. package/dist/assets/{sortable-tabs-strip-CudoSgV4.js → sortable-tabs-strip-DyEV0Heg.js} +1 -1
  65. package/dist/assets/src-ubCWXZhR.css +2 -0
  66. package/dist/assets/store-DLCiZaA6.js +15 -0
  67. package/dist/assets/{terminalApi-DiF6JmBP.js → terminalApi-CXaBEoa_.js} +1 -1
  68. package/dist/assets/textarea-BBOlRI9O.js +1 -0
  69. package/dist/assets/{tooltip-CQvpK-UV.js → tooltip-CPI4fr1a.js} +1 -1
  70. package/dist/assets/uk-rwQrxeYM.js +15 -0
  71. package/dist/assets/{url-_z7evzdK.js → url-DRDQXjyX.js} +1 -1
  72. package/dist/assets/{useAppFontEffects-Cpmpo7Wb.js → useAppFontEffects-CiDe42I5.js} +57 -43
  73. package/dist/assets/{useAssistantUIStore-CMutVh9B.js → useAssistantUIStore-asNG6K7n.js} +1 -1
  74. package/dist/assets/useDesktopWindowControlsLayout-BlizAlOt.js +1 -0
  75. package/dist/assets/useEffectiveDirectory-C_CouL5v.js +1 -0
  76. package/dist/assets/{useFontPreferences-DcbXQOkc.js → useFontPreferences-B0oUZAiB.js} +1 -1
  77. package/dist/assets/useMobileNavigationStore-DfPClaW-.js +414 -0
  78. package/dist/assets/{useRuntimeAPIs-DSLXJybW.js → useRuntimeAPIs-DwLtO0c_.js} +1 -1
  79. package/dist/assets/useSessionAutoCleanup-DtrfbxAp.js +2 -0
  80. package/dist/assets/{useThemeSystem-DMzWVJaW.js → useThemeSystem-BlNUWH47.js} +1 -1
  81. package/dist/assets/useUIStore-CQYKJnrg.js +1 -0
  82. package/dist/assets/{useWorkerHighlightedLines-Bqn4AiBL.js → useWorkerHighlightedLines-CPNYz6Wf.js} +2 -2
  83. package/dist/assets/{vendor-base-ui-CnHfUmDN.js → vendor-base-ui-CDjyofeD.js} +1 -1
  84. package/dist/assets/{vendor-cmdk-CeAGfrLo.js → vendor-cmdk-BFH0UpXX.js} +1 -1
  85. package/dist/assets/{vendor-dnd-kit-accessibility-BY5LhhUB.js → vendor-dnd-kit-accessibility-BrR3AKvM.js} +1 -1
  86. package/dist/assets/{vendor-dnd-kit-core-CvWxUPdb.js → vendor-dnd-kit-core-CNfdvk6H.js} +1 -1
  87. package/dist/assets/{vendor-dnd-kit-sortable-hHdP9lLb.js → vendor-dnd-kit-sortable-DQUaTeGj.js} +1 -1
  88. package/dist/assets/{vendor-framer-motion-DFy_BSVd.js → vendor-framer-motion-4dogsUk3.js} +1 -1
  89. package/dist/assets/{vendor-legendapp-list-WE32lwNk.js → vendor-legendapp-list-BU7MdPZR.js} +1 -1
  90. package/dist/assets/{vendor-markstream-react-DX55XLGO.js → vendor-markstream-react-qHC2FykU.js} +3 -3
  91. package/dist/assets/{vendor-pierre-diffs-DX7XTPG3.js → vendor-pierre-diffs-CUfqC003.js} +1 -1
  92. package/dist/assets/{vendor-react-DLPElpoQ.js → vendor-react-C94A0AHl.js} +1 -1
  93. package/dist/assets/{vendor-react-drawio-ECtEwvbc.js → vendor-react-drawio-FVlWqKVE.js} +1 -1
  94. package/dist/assets/{vendor-reactuses-core-BnW6GxJt.js → vendor-reactuses-core-C2A3EjUi.js} +1 -1
  95. package/dist/assets/{vendor-sonner-DVzioF3d.js → vendor-sonner-DNqHmUQs.js} +1 -1
  96. package/dist/assets/{vendor-stream-diffs-DWy3xSmp.js → vendor-stream-diffs-CwbKsmlv.js} +2 -2
  97. package/dist/assets/{vendor-tanstack-react-query-D7pys_c9.js → vendor-tanstack-react-query-D0iFK57C.js} +1 -1
  98. package/dist/assets/{vendor-tanstack-react-virtual-tlDgH6PG.js → vendor-tanstack-react-virtual-B2kV6cnT.js} +1 -1
  99. package/dist/assets/{vendor-zustand-B4dlGEM2.js → vendor-zustand-BqLKf6ZY.js} +1 -1
  100. package/dist/assets/zh-CN-BvmBDnkD.js +15 -0
  101. package/dist/assets/zh-TW-9wcHTX0y.js +15 -0
  102. package/dist/index.html +13 -13
  103. package/dist/mini-chat.html +13 -13
  104. package/dist/mobile.html +13 -13
  105. package/package.json +1 -1
  106. package/server/index.js +327 -62
  107. package/server/lib/assistants/DOCUMENTATION.md +14 -9
  108. package/server/lib/assistants/contracts.js +11 -1
  109. package/server/lib/assistants/projection-history.test.js +265 -18
  110. package/server/lib/assistants/routes.js +6 -0
  111. package/server/lib/assistants/service.js +417 -231
  112. package/server/lib/assistants/service.test.js +392 -148
  113. package/server/lib/conversations/DOCUMENTATION.md +1 -1
  114. package/server/lib/conversations/service.js +1 -0
  115. package/server/lib/conversations/service.test.js +16 -0
  116. package/server/lib/conversations/validation.js +11 -1
  117. package/server/lib/event-stream/DOCUMENTATION.md +3 -1
  118. package/server/lib/event-stream/directory-ws-bridge.js +13 -2
  119. package/server/lib/event-stream/global-hub.js +49 -4
  120. package/server/lib/event-stream/reasoning-projection.js +16 -1
  121. package/server/lib/event-stream/reasoning-projection.test.js +15 -0
  122. package/server/lib/event-stream/runtime.js +57 -20
  123. package/server/lib/event-stream/runtime.test.js +112 -0
  124. package/server/lib/llm/DOCUMENTATION.md +33 -9
  125. package/server/lib/llm/catalog.js +1 -1
  126. package/server/lib/llm/completions.js +5 -1
  127. package/server/lib/llm/completions.test.js +3 -3
  128. package/server/lib/llm/generate.js +237 -145
  129. package/server/lib/llm/generate.test.js +201 -46
  130. package/server/lib/llm/routes.js +2 -0
  131. package/server/lib/message-queue/runtime.js +15 -2
  132. package/server/lib/notifications/runtime.js +23 -5
  133. package/server/lib/notifications/runtime.test.js +11 -0
  134. package/server/lib/opencode/DOCUMENTATION.md +85 -17
  135. package/server/lib/opencode/agent-document.d.ts +28 -0
  136. package/server/lib/opencode/agent-document.js +280 -0
  137. package/server/lib/opencode/agent-document.test.js +137 -0
  138. package/server/lib/opencode/agents.js +96 -0
  139. package/server/lib/opencode/auth-state-runtime.js +5 -0
  140. package/server/lib/opencode/bootstrap-runtime.js +2 -0
  141. package/server/lib/opencode/config-entity-routes.js +42 -99
  142. package/server/lib/opencode/config-entity-routes.test.js +107 -0
  143. package/server/lib/opencode/config-hot-reload.integration.test.js +105 -0
  144. package/server/lib/opencode/ensure-cli.js +1 -2
  145. package/server/lib/opencode/env-runtime.js +40 -26
  146. package/server/lib/opencode/env-runtime.test.js +10 -17
  147. package/server/lib/opencode/feature-routes-runtime.js +43 -4
  148. package/server/lib/opencode/feature-routes-runtime.test.js +18 -0
  149. package/server/lib/opencode/http-server-connections.js +98 -0
  150. package/server/lib/opencode/http-server-connections.test.js +321 -0
  151. package/server/lib/opencode/lifecycle.js +349 -39
  152. package/server/lib/opencode/lifecycle.test.js +322 -0
  153. package/server/lib/opencode/managed-capabilities-runtime.js +15 -2
  154. package/server/lib/opencode/managed-capabilities-runtime.test.js +1 -0
  155. package/server/lib/opencode/openchamber-routes.js +7 -1
  156. package/server/lib/opencode/opencode2-pin.js +4 -4
  157. package/server/lib/opencode/opencode2-pin.test.js +1 -0
  158. package/server/lib/opencode/opencode2-sidecar.e2e.test.js +127 -4
  159. package/server/lib/opencode/owned-runtime-upgrade.js +431 -0
  160. package/server/lib/opencode/owned-runtime-upgrade.test.js +160 -0
  161. package/server/lib/opencode/plugin-routes.js +3 -18
  162. package/server/lib/opencode/plugin-routes.test.js +9 -9
  163. package/server/lib/opencode/provider-catalog.js +58 -12
  164. package/server/lib/opencode/provider-catalog.test.js +48 -0
  165. package/server/lib/opencode/proxy.js +252 -46
  166. package/server/lib/opencode/proxy.test.js +235 -2
  167. package/server/lib/opencode/routes.js +296 -62
  168. package/server/lib/opencode/routes.test.js +278 -12
  169. package/server/lib/opencode/runtime-contract.js +401 -0
  170. package/server/lib/opencode/runtime-contract.test.js +214 -0
  171. package/server/lib/opencode/server-opencode-fetch.js +242 -0
  172. package/server/lib/opencode/server-opencode-fetch.test.js +130 -0
  173. package/server/lib/opencode/server-utils-runtime.js +33 -0
  174. package/server/lib/opencode/shared-service.integration.test.js +66 -0
  175. package/server/lib/opencode/shared-service.js +147 -0
  176. package/server/lib/opencode/shared-service.test.js +93 -0
  177. package/server/lib/opencode/shared.js +3 -3
  178. package/server/lib/opencode/shutdown-runtime.js +15 -6
  179. package/server/lib/opencode/shutdown-runtime.test.js +49 -1
  180. package/server/lib/opencode/skill-routes.js +20 -33
  181. package/server/lib/opencode/skill-routes.test.js +35 -2
  182. package/server/lib/opencode/skills.test.js +15 -0
  183. package/server/lib/opencode/startup-pipeline-runtime.js +4 -0
  184. package/server/lib/opencode/static-routes-runtime.js +3 -1
  185. package/server/lib/opencode/static-routes-runtime.test.js +33 -0
  186. package/server/lib/opencode/v2-client.js +14 -3
  187. package/server/lib/opencode/v2-plugin-host-shim/index.mjs +155 -0
  188. package/server/lib/opencode/v2-plugin-host-shim/package.json +4 -0
  189. package/server/lib/permission-auto-accept/DOCUMENTATION.md +5 -5
  190. package/server/lib/permission-auto-accept/runtime.js +14 -7
  191. package/server/lib/permission-auto-accept/runtime.test.js +27 -12
  192. package/server/lib/relay/reasoning-projection.e2e.test.ts +22 -7
  193. package/server/lib/scheduled-tasks/DOCUMENTATION.md +8 -0
  194. package/server/lib/scheduled-tasks/runtime.js +52 -4
  195. package/server/lib/scheduled-tasks/runtime.test.js +80 -1
  196. package/server/lib/session-goal/DOCUMENTATION.md +113 -13
  197. package/server/lib/session-goal/routes.js +9 -0
  198. package/server/lib/session-goal/runtime.js +1579 -117
  199. package/server/lib/session-goal/runtime.test.js +1453 -84
  200. package/server/lib/session-index/DOCUMENTATION.md +18 -15
  201. package/server/lib/session-index/event-ingest.js +89 -16
  202. package/server/lib/session-index/event-ingest.test.js +73 -0
  203. package/server/lib/session-index/host-authority-snapshot.js +92 -0
  204. package/server/lib/session-index/routes.host-archive.test.js +201 -0
  205. package/server/lib/session-index/routes.js +63 -10
  206. package/server/lib/session-index/routes.test.js +8 -8
  207. package/server/lib/session-index/service.js +7 -1
  208. package/server/lib/session-index/sync-runtime.js +161 -34
  209. package/server/lib/session-index/sync-runtime.test.js +155 -1
  210. package/server/lib/session-metadata/DOCUMENTATION.md +61 -16
  211. package/server/lib/session-metadata/routes.js +108 -9
  212. package/server/lib/session-metadata/routes.test.js +47 -2
  213. package/server/lib/session-metadata/session-archive.d.ts +73 -0
  214. package/server/lib/session-metadata/session-archive.js +654 -0
  215. package/server/lib/session-metadata/session-archive.test.js +359 -0
  216. package/server/lib/session-metadata/session-metadata-store.d.ts +36 -0
  217. package/server/lib/session-metadata/session-metadata-store.js +212 -72
  218. package/server/lib/session-metadata/session-metadata-store.test.js +205 -10
  219. package/server/lib/session-metadata/session-projection.d.ts +41 -0
  220. package/server/lib/session-metadata/session-projection.js +255 -0
  221. package/server/lib/session-metadata/session-projection.test.js +64 -0
  222. package/server/lib/session-metadata/system-session.js +55 -0
  223. package/server/lib/session-metadata/system-session.test.js +55 -0
  224. package/server/lib/session-title/DOCUMENTATION.md +40 -7
  225. package/server/lib/session-title/native-runtime.test.js +167 -0
  226. package/server/lib/session-title/runtime.js +67 -42
  227. package/server/lib/session-title/session-access.js +46 -0
  228. package/server/lib/session-title/session-access.test.js +91 -0
  229. package/server/lib/session-turn-pages/DOCUMENTATION.md +9 -8
  230. package/server/lib/session-turn-pages/routes.js +17 -102
  231. package/server/lib/session-turn-pages/routes.test.js +281 -0
  232. package/server/lib/session-turn-pages/session-message-projection.d.ts +13 -0
  233. package/server/lib/session-turn-pages/session-message-projection.js +281 -0
  234. package/server/lib/small-model/DOCUMENTATION.md +101 -147
  235. package/server/lib/small-model/catalog.js +31 -51
  236. package/server/lib/small-model/catalog.test.js +65 -10
  237. package/server/lib/small-model/custom-api.js +68 -0
  238. package/server/lib/small-model/custom-api.test.js +64 -0
  239. package/server/lib/small-model/index.js +124 -240
  240. package/server/lib/small-model/resolve.js +35 -120
  241. package/server/lib/small-model/resolve.test.js +20 -110
  242. package/server/lib/small-model/routes.js +1 -3
  243. package/server/lib/small-model/summary-settings.test.js +157 -253
  244. package/server/opencode-proxy.test.js +2 -0
  245. package/dist/assets/AssistantView-BTCkm8yk.js +0 -3
  246. package/dist/assets/MarkstreamRendererImpl-CnkodGJW.js +0 -3
  247. package/dist/assets/MobileSurface-BXLeiZ4d.js +0 -2
  248. package/dist/assets/OverlayScrollbar-pMwxaCca.js +0 -2
  249. package/dist/assets/SettingsView-B5I0KDTZ.js +0 -25
  250. package/dist/assets/appThemeRegistry-COGfxTFV.js +0 -1
  251. package/dist/assets/collapsible-BXRd5_Hr.js +0 -1
  252. package/dist/assets/context-menu-jGtH6fR9.js +0 -1
  253. package/dist/assets/es-c52LTHHe.js +0 -15
  254. package/dist/assets/fr-PisFbUE8.js +0 -13
  255. package/dist/assets/gitApi-ButwzQ0N.js +0 -9
  256. package/dist/assets/insertionBoundaries-DGs7S5zv.js +0 -1
  257. package/dist/assets/ja-C8Ikun3Z.js +0 -15
  258. package/dist/assets/ko-ClFys5o2.js +0 -15
  259. package/dist/assets/main-Dn9rpsgl.js +0 -169
  260. package/dist/assets/markdown-worker-CptoICmq.js +0 -2
  261. package/dist/assets/miniChat-BpjxVqgg.js +0 -2
  262. package/dist/assets/mobile-nMJCaNq1.js +0 -2
  263. package/dist/assets/multirun-BvSG94mW.js +0 -4
  264. package/dist/assets/pl-CnaKWN5F.js +0 -15
  265. package/dist/assets/platform-CWTIp02q.js +0 -4
  266. package/dist/assets/projectMeta-DOe49dM_.js +0 -3
  267. package/dist/assets/pt-BR-s8OpfRI6.js +0 -15
  268. package/dist/assets/renderElectronMiniChatApp-R6dKh79f.js +0 -2
  269. package/dist/assets/renderMobileApp-y-S8y8v_.js +0 -28
  270. package/dist/assets/runtime-fetch-zIalUnbb.js +0 -1
  271. package/dist/assets/runtimeEndpointReset-B6BxhvR0.js +0 -147
  272. package/dist/assets/sessionChangeSummary-VVBlEXuh.js +0 -2
  273. package/dist/assets/src-BzX5UI-c.css +0 -2
  274. package/dist/assets/store-DJczmczv.js +0 -15
  275. package/dist/assets/textarea-3XR8sUfy.js +0 -1
  276. package/dist/assets/uk-CaX0iqkB.js +0 -15
  277. package/dist/assets/useDesktopWindowControlsLayout-B1GX59-L.js +0 -1
  278. package/dist/assets/useEffectiveDirectory-CN6JoCFw.js +0 -1
  279. package/dist/assets/useMobileNavigationStore-BVjFtXSy.js +0 -414
  280. package/dist/assets/useSessionAutoCleanup-BLjymaFt.js +0 -2
  281. package/dist/assets/useUIStore-Bxv3f23A.js +0 -1
  282. package/dist/assets/zh-CN-20MYEjha.js +0 -15
  283. package/dist/assets/zh-TW-DiN04qTw.js +0 -15
  284. package/server/lib/small-model/call.js +0 -427
  285. package/server/lib/small-model/call.test.js +0 -382
  286. package/server/lib/small-model/opencode-session.js +0 -183
  287. package/server/lib/small-model/opencode-session.test.js +0 -147
@@ -18,6 +18,7 @@ import fs from 'fs';
18
18
  import os from 'os';
19
19
  import path from 'path';
20
20
 
21
+ import { makeOpenCodeV2Client } from '../opencode/v2-client.js';
21
22
  import { GOAL_OBJECTIVE_CHAR_LIMIT, readObjective } from './objectives.js';
22
23
 
23
24
  const OPENCHAMBER_SETTINGS_FILE = path.join(
@@ -56,9 +57,62 @@ const MAX_AUTO_TURNS = 20;
56
57
  // goal settles as blocked — a one-off snag must not end the goal.
57
58
  const BLOCKED_STREAK_LIMIT = 3;
58
59
  const GOAL_STATUSES = ['active', 'paused', 'blocked', 'budgetLimited', 'complete'];
60
+ /** Durable continuation admission phases on goal.pendingContinuation. */
61
+ const PENDING_CONTINUATION_PHASES = ['reserved', 'transport', 'uncertain', 'accepted'];
62
+ /** User-visible reason when upstream reconcile cannot decide delivery. */
63
+ const CONTINUATION_UNCERTAIN_REASON = 'continuation delivery uncertain — paused auto-continue';
59
64
 
60
65
  const clampText = (value, limit) => String(value ?? '').trim().slice(0, limit);
61
66
 
67
+ /** Durable execution generation on a goal record (missing → 0). */
68
+ export const readGoalExecutionGeneration = (goal) => (
69
+ Number.isFinite(goal?.executionGeneration) && goal.executionGeneration >= 0
70
+ ? Math.floor(goal.executionGeneration)
71
+ : 0
72
+ );
73
+
74
+ export const nextGoalExecutionGeneration = (goal) => readGoalExecutionGeneration(goal) + 1;
75
+
76
+ /**
77
+ * User-facing transitions that invalidate in-flight audit/dispatch work.
78
+ * Settle paths (active → complete/blocked/budgetLimited) keep the generation.
79
+ */
80
+ export const shouldAdvanceGoalExecutionGeneration = (previous, next) => {
81
+ if (!previous || !next || typeof previous !== 'object' || typeof next !== 'object') return false;
82
+ if (previous.id !== next.id) return false;
83
+ if (next.status === 'active' && previous.status !== 'active') return true;
84
+ if (next.status === 'paused' && previous.status === 'active') return true;
85
+ if (previous.tokenBudget !== next.tokenBudget) return true;
86
+ if ((previous.objective || '') !== (next.objective || '')) return true;
87
+ if (Boolean(previous.objectiveFile) !== Boolean(next.objectiveFile)) return true;
88
+ return false;
89
+ };
90
+
91
+ /**
92
+ * Ensure a goal write carries a monotonic executionGeneration when the
93
+ * transition requires a new execution permit (pause / resume / conditions).
94
+ */
95
+ export const withGoalExecutionGeneration = (previous, next) => {
96
+ const goal = next && typeof next === 'object' ? { ...next } : next;
97
+ if (!goal || typeof goal !== 'object') return goal;
98
+ if (!previous || previous.id !== goal.id) {
99
+ if (!Number.isFinite(goal.executionGeneration) || goal.executionGeneration < 0) {
100
+ goal.executionGeneration = 0;
101
+ } else {
102
+ goal.executionGeneration = Math.floor(goal.executionGeneration);
103
+ }
104
+ return goal;
105
+ }
106
+ const prevGen = readGoalExecutionGeneration(previous);
107
+ const nextGen = readGoalExecutionGeneration(goal);
108
+ if (shouldAdvanceGoalExecutionGeneration(previous, goal)) {
109
+ goal.executionGeneration = Math.max(nextGen, prevGen + 1);
110
+ } else {
111
+ goal.executionGeneration = Math.max(nextGen, prevGen);
112
+ }
113
+ return goal;
114
+ };
115
+
62
116
  const escapeXmlText = (value) => String(value ?? '')
63
117
  .replace(/&/g, '&amp;')
64
118
  .replace(/</g, '&lt;')
@@ -158,15 +212,33 @@ const extractSessionStatus = (payload) => {
158
212
  return { sessionId, type, directory };
159
213
  };
160
214
 
161
- // A user abort lands as an assistant message carrying MessageAbortedError.
162
- const extractAbortedAssistant = (payload) => {
163
- if (!payload || payload.type !== 'message.updated') return null;
164
- const info = payload.properties?.info;
165
- if (!info || typeof info !== 'object' || info.role !== 'assistant') return null;
166
- if (info.error?.name !== 'MessageAbortedError') return null;
167
- if (typeof info.sessionID !== 'string' || !info.sessionID) return null;
168
- return { sessionId: info.sessionID };
169
- };
215
+ // A user abort lands as an assistant message carrying MessageAbortedError.
216
+ const extractAbortedAssistant = (payload) => {
217
+ if (!payload || payload.type !== 'message.updated') return null;
218
+ const info = payload.properties?.info ?? payload.data?.info;
219
+ if (!info || typeof info !== 'object' || (info.role !== 'assistant' && info.type !== 'assistant')) return null;
220
+ if (info.error?.name !== 'MessageAbortedError') return null;
221
+ if (typeof info.sessionID !== 'string' || !info.sessionID) return null;
222
+ return { sessionId: info.sessionID };
223
+ };
224
+
225
+ /**
226
+ * Official v2 `session.execution.interrupted` reason:
227
+ * user | shutdown | superseded | inactivity.
228
+ * Shutdown keeps the execution claim for restart recovery — do not settle or auto-continue.
229
+ */
230
+ const extractExecutionInterrupted = (payload) => {
231
+ if (!payload || payload.type !== 'session.execution.interrupted') return null;
232
+ const body = (payload.properties && typeof payload.properties === 'object')
233
+ ? payload.properties
234
+ : ((payload.data && typeof payload.data === 'object') ? payload.data : null);
235
+ if (!body) return null;
236
+ const sessionId = typeof body.sessionID === 'string' ? body.sessionID.trim() : '';
237
+ if (!sessionId) return null;
238
+ const reason = typeof body.reason === 'string' ? body.reason.trim() : '';
239
+ return { sessionId, reason };
240
+ };
241
+
170
242
 
171
243
  // QuestionRequest shape: properties.sessionID, optional properties.directory.
172
244
  const extractQuestionAsked = (payload, directoryHint = '') => {
@@ -221,18 +293,213 @@ const parseGoalMetadata = (session) => {
221
293
  note: typeof goal.note === 'string' ? goal.note.slice(0, NOTE_CHAR_LIMIT) : '',
222
294
  statusReason: typeof goal.statusReason === 'string' ? goal.statusReason.slice(0, REASON_CHAR_LIMIT) : '',
223
295
  lastAccountedMessageID: typeof goal.lastAccountedMessageID === 'string' ? goal.lastAccountedMessageID : '',
296
+ executionGeneration: readGoalExecutionGeneration(goal),
297
+ pendingContinuation: parsePendingContinuation(goal.pendingContinuation),
224
298
  createdAt: Number.isFinite(goal.createdAt) ? goal.createdAt : 0,
225
299
  updatedAt: Number.isFinite(goal.updatedAt) ? goal.updatedAt : 0,
226
300
  };
227
301
  };
228
302
 
303
+ /**
304
+ * Official v2 `SessionMessagesResponse` is `{ data: SessionMessageInfo[], cursor }`.
305
+ * Legacy bare arrays and projected `{ info, parts }` still appear in tests/fixtures.
306
+ * Never treat a failed/malformed payload as authoritative empty success.
307
+ */
308
+ export const unwrapSessionMessages = (payload) => {
309
+ if (Array.isArray(payload)) return payload;
310
+ if (payload && typeof payload === 'object') {
311
+ if (Array.isArray(payload.data)) return payload.data;
312
+ if (Array.isArray(payload.data?.items)) return payload.data.items;
313
+ if (Array.isArray(payload.messages)) return payload.messages;
314
+ }
315
+ return null;
316
+ };
317
+
318
+ /** Project one list entry to `{ info, parts }` for goal accounting / audit text. */
319
+ export const projectGoalMessage = (entry, sessionID = '') => {
320
+ if (!entry || typeof entry !== 'object') return null;
321
+ if (entry.info && typeof entry.info === 'object') {
322
+ return {
323
+ info: entry.info,
324
+ parts: Array.isArray(entry.parts) ? entry.parts : [],
325
+ };
326
+ }
327
+ const id = typeof entry.id === 'string' ? entry.id : '';
328
+ if (!id) return null;
329
+ const role = entry.type === 'user' || entry.type === 'assistant' || entry.type === 'compaction'
330
+ ? (entry.type === 'compaction' ? 'assistant' : entry.type)
331
+ : (typeof entry.role === 'string' ? entry.role : entry.type);
332
+ const parts = [];
333
+ if (typeof entry.text === 'string' && entry.text) {
334
+ parts.push({ id: `${id}:text`, sessionID, messageID: id, type: 'text', text: entry.text });
335
+ }
336
+ if (Array.isArray(entry.content)) {
337
+ entry.content.forEach((item, index) => {
338
+ const partID = item?.id || `${id}:content:${index}`;
339
+ if (item?.type === 'text' && typeof item.text === 'string') {
340
+ parts.push({ id: partID, sessionID, messageID: id, type: 'text', text: item.text });
341
+ } else if (item?.type === 'reasoning' && typeof item.text === 'string') {
342
+ parts.push({ id: partID, sessionID, messageID: id, type: 'reasoning', text: item.text });
343
+ }
344
+ });
345
+ }
346
+ if (entry.type === 'compaction' && typeof entry.summary === 'string' && entry.summary) {
347
+ parts.push({ id: `${id}:summary`, sessionID, messageID: id, type: 'text', text: entry.summary });
348
+ }
349
+ const info = {
350
+ ...entry,
351
+ id,
352
+ role: role || entry.role,
353
+ // Compaction is the v2 summary turn; keep summary flag for existing tick rules.
354
+ ...(entry.type === 'compaction' ? { summary: true } : {}),
355
+ };
356
+ return { info, parts };
357
+ };
358
+
359
+ export const projectGoalMessages = (payload, sessionID = '') => {
360
+ const raw = unwrapSessionMessages(payload);
361
+ if (!raw) return null;
362
+ const projected = [];
363
+ for (const entry of raw) {
364
+ const message = projectGoalMessage(entry, sessionID);
365
+ if (message) projected.push(message);
366
+ }
367
+ return projected;
368
+ };
369
+
370
+ const messageRole = (info) => {
371
+ if (!info || typeof info !== 'object') return '';
372
+ if (typeof info.role === 'string' && info.role) return info.role;
373
+ if (info.type === 'user' || info.type === 'assistant') return info.type;
374
+ if (info.type === 'compaction') return 'assistant';
375
+ return typeof info.type === 'string' ? info.type : '';
376
+ };
377
+
378
+ const isSummaryMessage = (info) => (
379
+ info?.summary === true || info?.type === 'compaction'
380
+ );
381
+
382
+ /**
383
+ * Resolve provider/model for switchModel + continuation from last assistant.
384
+ * v2 uses `model: { id, providerID, variant? }`; legacy fixtures use flat fields.
385
+ */
386
+ export const extractExecutionModel = (info) => {
387
+ if (!info || typeof info !== 'object') {
388
+ return { providerID: '', modelID: '', variant: '', agent: '' };
389
+ }
390
+ const model = info.model && typeof info.model === 'object' ? info.model : null;
391
+ const providerID = typeof model?.providerID === 'string' && model.providerID
392
+ ? model.providerID
393
+ : (typeof info.providerID === 'string' ? info.providerID : '');
394
+ const modelID = typeof model?.id === 'string' && model.id
395
+ ? model.id
396
+ : (typeof model?.modelID === 'string' && model.modelID
397
+ ? model.modelID
398
+ : (typeof info.modelID === 'string' ? info.modelID : ''));
399
+ const variant = typeof model?.variant === 'string' && model.variant
400
+ ? model.variant
401
+ : (typeof info.variant === 'string' ? info.variant : '');
402
+ const agent = typeof info.agent === 'string' && info.agent
403
+ ? info.agent
404
+ : (typeof info.mode === 'string' ? info.mode : '');
405
+ return { providerID, modelID, variant, agent };
406
+ };
407
+
408
+ /**
409
+ * Stable continuation message id for v2 `session.prompt` payload `id`.
410
+ * Official SessionMessage.ID requires `startsWith('msg_')`; suffix stays
411
+ * deterministic on goalId + generation + turnsUsed for idempotent re-dispatch.
412
+ */
413
+ export const buildContinuationMessageID = ({ goalId, generation, turnsUsed }) => {
414
+ const safeGoal = String(goalId || 'goal')
415
+ .replace(/[^a-zA-Z0-9_-]/g, '')
416
+ .slice(0, 48) || 'goal';
417
+ const gen = Number.isFinite(generation) ? Math.floor(generation) : 0;
418
+ const turns = Number.isFinite(turnsUsed) ? Math.floor(turnsUsed) : 0;
419
+ return `msg_goalc_${safeGoal}_${gen}_${turns}`;
420
+ };
421
+
422
+ /**
423
+ * Parse durable continuation admission from goal metadata (ticket 10).
424
+ * Null when absent or malformed — never invent an empty success admission.
425
+ */
426
+ export const parsePendingContinuation = (raw) => {
427
+ if (!raw || typeof raw !== 'object') return null;
428
+ const messageID = typeof raw.messageID === 'string' ? raw.messageID.trim() : '';
429
+ const goalId = typeof raw.goalId === 'string' ? raw.goalId.trim() : '';
430
+ const phase = PENDING_CONTINUATION_PHASES.includes(raw.phase) ? raw.phase : '';
431
+ if (!messageID || !goalId || !phase) return null;
432
+ const generation = Number.isFinite(raw.generation) && raw.generation >= 0
433
+ ? Math.floor(raw.generation)
434
+ : 0;
435
+ const turnsUsed = Number.isFinite(raw.turnsUsed) && raw.turnsUsed > 0
436
+ ? Math.floor(raw.turnsUsed)
437
+ : 0;
438
+ if (!turnsUsed) return null;
439
+ const text = typeof raw.text === 'string' ? raw.text : '';
440
+ const providerID = typeof raw.providerID === 'string' ? raw.providerID : '';
441
+ const modelID = typeof raw.modelID === 'string' ? raw.modelID : '';
442
+ if (!text || !providerID || !modelID) return null;
443
+ return {
444
+ messageID,
445
+ goalId,
446
+ generation,
447
+ turnsUsed,
448
+ phase,
449
+ text,
450
+ providerID,
451
+ modelID,
452
+ variant: typeof raw.variant === 'string' ? raw.variant : '',
453
+ agent: typeof raw.agent === 'string' ? raw.agent : '',
454
+ error: typeof raw.error === 'string' ? raw.error.slice(0, REASON_CHAR_LIMIT) : '',
455
+ at: Number.isFinite(raw.at) ? raw.at : 0,
456
+ };
457
+ };
458
+
459
+ /** Build a durable admission record for conditional metadata writes. */
460
+ export const buildPendingContinuation = ({
461
+ messageID,
462
+ goalId,
463
+ generation,
464
+ turnsUsed,
465
+ phase,
466
+ text,
467
+ providerID,
468
+ modelID,
469
+ variant = '',
470
+ agent = '',
471
+ error = '',
472
+ at = Date.now(),
473
+ }) => ({
474
+ messageID: String(messageID || ''),
475
+ goalId: String(goalId || ''),
476
+ generation: Number.isFinite(generation) ? Math.floor(generation) : 0,
477
+ turnsUsed: Number.isFinite(turnsUsed) ? Math.floor(turnsUsed) : 0,
478
+ phase: PENDING_CONTINUATION_PHASES.includes(phase) ? phase : 'reserved',
479
+ text: String(text || ''),
480
+ providerID: String(providerID || ''),
481
+ modelID: String(modelID || ''),
482
+ variant: typeof variant === 'string' ? variant : '',
483
+ agent: typeof agent === 'string' ? agent : '',
484
+ ...(error ? { error: clampText(error, REASON_CHAR_LIMIT) } : {}),
485
+ at: Number.isFinite(at) ? at : Date.now(),
486
+ });
487
+
229
488
  const messagePartsToText = (message) => {
230
489
  const parts = Array.isArray(message?.parts) ? message.parts : [];
231
- return parts
490
+ const fromParts = parts
232
491
  .map((part) => (part?.type === 'text' && typeof part.text === 'string' ? part.text : ''))
233
492
  .filter(Boolean)
234
- .join('\n')
235
- .slice(0, TRANSCRIPT_PART_CHAR_LIMIT);
493
+ .join('\n');
494
+ if (fromParts) return fromParts.slice(0, TRANSCRIPT_PART_CHAR_LIMIT);
495
+ // Raw v2 assistant/user may carry top-level text when parts were not projected.
496
+ if (typeof message?.info?.text === 'string' && message.info.text) {
497
+ return message.info.text.slice(0, TRANSCRIPT_PART_CHAR_LIMIT);
498
+ }
499
+ if (typeof message?.text === 'string' && message.text) {
500
+ return message.text.slice(0, TRANSCRIPT_PART_CHAR_LIMIT);
501
+ }
502
+ return '';
236
503
  };
237
504
 
238
505
  // Per-turn spend for goal accounting. Sum these across turns completed after
@@ -262,8 +529,8 @@ export const accountGoalTokenSpend = ({ messages, goal }) => {
262
529
  const createdAt = Number.isFinite(goal?.createdAt) ? goal.createdAt : 0;
263
530
  let addedSpend = 0;
264
531
  for (const message of messages || []) {
265
- const info = message?.info;
266
- if (info?.role !== 'assistant' || typeof info.id !== 'string') continue;
532
+ const info = message?.info ?? message;
533
+ if (messageRole(info) !== 'assistant' || typeof info?.id !== 'string') continue;
267
534
  if (!(info.time?.completed > 0)) continue;
268
535
  // First tick with an empty cursor: only charge turns that finished after
269
536
  // the goal was created so mid-session goals skip prior history.
@@ -273,7 +540,7 @@ export const accountGoalTokenSpend = ({ messages, goal }) => {
273
540
  if (lastAccountedMessageID && info.id <= lastAccountedMessageID) continue;
274
541
  // Summary turns report 0 tokens from OpenCode — skip without advancing
275
542
  // the counter, but still move the cursor so we do not re-scan them.
276
- if (info.summary !== true) {
543
+ if (!isSummaryMessage(info)) {
277
544
  addedSpend += messageTokenSpend(info);
278
545
  }
279
546
  if (!lastAccountedMessageID || info.id > lastAccountedMessageID) {
@@ -301,6 +568,8 @@ const contactStatusFromGoal = (status) => {
301
568
  export const createSessionGoalRuntime = ({
302
569
  buildOpenCodeUrl,
303
570
  getOpenCodeAuthHeaders,
571
+ /** Ticket 11: Host write admission shared with proxy (optional; falls back to raw fetch). */
572
+ serverOpenCodeFetch = null,
304
573
  getSmallModelService,
305
574
  emitGoalNotification,
306
575
  idleQuietMs = IDLE_QUIET_MS,
@@ -324,16 +593,60 @@ export const createSessionGoalRuntime = ({
324
593
  * state is read/written only through the Host store (OpenCode 2.x has no
325
594
  * session-metadata PATCH). When either is missing, keep the legacy OpenCode
326
595
  * PATCH path so existing tests and unwired servers still work.
596
+ *
597
+ * `mutateSessionMetadata` (optional) is the preferred commit seam: decide runs
598
+ * on the store's exclusive serial boundary so generation/status checks and
599
+ * persist share one authority lock.
327
600
  */
328
601
  persistSessionGoal = null,
329
602
  readSessionMetadata = null,
603
+ mutateSessionMetadata = null,
330
604
  }) => {
331
605
  const timers = new Map();
332
606
  const inflight = new Set();
607
+ /** sessionId → AbortController for the in-flight audit (cancellable work). */
608
+ const auditControllers = new Map();
609
+ /**
610
+ * sessionId → monotonic dispatch epoch. Bumped synchronously on pause/cancel
611
+ * so a late audit cannot start a continuation after pause is confirmed in-process,
612
+ * even before the durable write lands.
613
+ */
614
+ const dispatchEpoch = new Map();
615
+ /**
616
+ * sessionId → last continuation dispatch identity that entered transport.
617
+ * Uncertain delivery is reconciled by identity; we never claim full revoke.
618
+ */
619
+ const dispatchedContinuations = new Map();
620
+ /**
621
+ * sessionId → shutdown recovery pending (ticket 07). Local runtime gate only —
622
+ * not a parallel durable store. Cleared on authoritative idle / non-shutdown terminal.
623
+ */
624
+ const shutdownRecoveryPending = new Map();
333
625
  let stopped = false;
334
626
 
627
+ const openCodeClient = () => makeOpenCodeV2Client({
628
+ baseUrl: buildOpenCodeUrl('/', '').replace(/\/$/, ''),
629
+ authHeaders: getOpenCodeAuthHeaders(),
630
+ });
631
+
632
+ const directoryRequestOptions = (directory, signal) => {
633
+ const headers = {};
634
+ if (typeof directory === 'string' && directory) {
635
+ headers['x-opencode-directory'] = encodeURIComponent(directory);
636
+ headers['x-opencode-directory-encoding'] = 'uri';
637
+ }
638
+ const options = {};
639
+ if (Object.keys(headers).length > 0) options.headers = headers;
640
+ if (signal) options.signal = signal;
641
+ return Object.keys(options).length > 0 ? options : undefined;
642
+ };
643
+
335
644
  const isMetadataStoreWired = () => (
336
- typeof persistSessionGoal === 'function' && typeof readSessionMetadata === 'function'
645
+ typeof readSessionMetadata === 'function'
646
+ && (
647
+ typeof mutateSessionMetadata === 'function'
648
+ || typeof persistSessionGoal === 'function'
649
+ )
337
650
  );
338
651
 
339
652
  const clearTimer = (sessionId) => {
@@ -344,7 +657,39 @@ export const createSessionGoalRuntime = ({
344
657
  }
345
658
  };
346
659
 
660
+ const bumpDispatchEpoch = (sessionId) => {
661
+ const next = (dispatchEpoch.get(sessionId) || 0) + 1;
662
+ dispatchEpoch.set(sessionId, next);
663
+ return next;
664
+ };
665
+
666
+ const currentDispatchEpoch = (sessionId) => dispatchEpoch.get(sessionId) || 0;
667
+
668
+ /** Cancel timers + in-flight audit; bump dispatch epoch. Does not claim upstream revoke. */
669
+ const revokeLocalExecutionPermit = (sessionId) => {
670
+ clearTimer(sessionId);
671
+ bumpDispatchEpoch(sessionId);
672
+ const controller = auditControllers.get(sessionId);
673
+ if (controller) {
674
+ try {
675
+ controller.abort();
676
+ } catch {
677
+ // ignore
678
+ }
679
+ auditControllers.delete(sessionId);
680
+ }
681
+ };
682
+
347
683
  const openCodeFetch = async (fetchPath, { directory, method = 'GET', body, query } = {}) => {
684
+ if (typeof serverOpenCodeFetch === 'function') {
685
+ return serverOpenCodeFetch(fetchPath, {
686
+ directory,
687
+ method,
688
+ body,
689
+ query,
690
+ timeoutMs: FETCH_TIMEOUT_MS,
691
+ });
692
+ }
348
693
  const base = buildOpenCodeUrl(fetchPath, '');
349
694
  const params = new URLSearchParams(query || {});
350
695
  if (directory) params.set('directory', directory);
@@ -366,12 +711,110 @@ export const createSessionGoalRuntime = ({
366
711
  return response.json().catch(() => null);
367
712
  };
368
713
 
714
+ /**
715
+ * Latest-side message page via official v2 `message.list`
716
+ * (`SessionMessagesResponse` = `{ data, cursor }`). Projects raw
717
+ * `SessionMessageInfo` into `{ info, parts }` for the rest of the tick.
718
+ * Fetch failure returns null (never empty success).
719
+ */
369
720
  const fetchRecentMessages = async (sessionId, directory) => {
370
- const messages = await openCodeFetch(`/session/${encodeURIComponent(sessionId)}/message`, {
721
+ try {
722
+ const client = openCodeClient();
723
+ if (typeof client?.message?.list === 'function') {
724
+ const listed = await client.message.list(
725
+ { sessionID: sessionId, limit: MESSAGE_FETCH_LIMIT, order: 'asc' },
726
+ directoryRequestOptions(directory),
727
+ );
728
+ const projected = projectGoalMessages(listed, sessionId);
729
+ if (projected) return projected;
730
+ }
731
+ } catch {
732
+ // Fall through to controlled HTTP seam for tests / older fixtures.
733
+ }
734
+ const raw = await openCodeFetch(`/session/${encodeURIComponent(sessionId)}/message`, {
371
735
  directory,
372
736
  query: { limit: String(MESSAGE_FETCH_LIMIT) },
373
737
  }).catch(() => null);
374
- return Array.isArray(messages) ? messages : null;
738
+ return projectGoalMessages(raw, sessionId);
739
+ };
740
+
741
+ /**
742
+ * Reconcile a fixed continuation message id via official inbox.list then
743
+ * session.message.get (or projected message list). Failures are unavailable
744
+ * — never treat transport/API failure as authoritative empty.
745
+ *
746
+ * Individual SDK/HTTP seams may be missing in tests or older serves; each
747
+ * seam falls through. Only when the authoritative projected list itself
748
+ * cannot be read do we return `unavailable` (fail ≠ empty).
749
+ *
750
+ * @returns {{ status: 'found' | 'absent' | 'unavailable' }}
751
+ */
752
+ const findContinuationMessage = async (sessionId, directory, messageID) => {
753
+ if (!messageID) return { status: 'unavailable' };
754
+ const requestOptions = directoryRequestOptions(directory);
755
+ const identityOf = (item) => {
756
+ if (!item || typeof item !== 'object') return '';
757
+ if (typeof item.id === 'string' && item.id) return item.id;
758
+ if (typeof item.messageID === 'string' && item.messageID) return item.messageID;
759
+ if (item.info && typeof item.info.id === 'string') return item.info.id;
760
+ return '';
761
+ };
762
+
763
+ try {
764
+ const client = openCodeClient();
765
+ if (typeof client?.session?.inbox?.list === 'function') {
766
+ try {
767
+ const listed = await client.session.inbox.list({ sessionID: sessionId }, requestOptions);
768
+ const items = Array.isArray(listed)
769
+ ? listed
770
+ : (Array.isArray(listed?.data) ? listed.data : null);
771
+ // Malformed inbox is not authoritative empty — fall through.
772
+ if (items && items.some((item) => identityOf(item) === messageID)) {
773
+ return { status: 'found' };
774
+ }
775
+ } catch {
776
+ // Missing inbox / transient — try exact message next.
777
+ }
778
+ }
779
+ if (typeof client?.session?.message === 'function') {
780
+ try {
781
+ const record = await client.session.message(
782
+ { sessionID: sessionId, messageID },
783
+ requestOptions,
784
+ );
785
+ if (identityOf(record) === messageID) {
786
+ return { status: 'found' };
787
+ }
788
+ } catch {
789
+ // Not-found or unsupported — fall through to HTTP / projection.
790
+ }
791
+ }
792
+ } catch {
793
+ // Fall through to HTTP seams.
794
+ }
795
+
796
+ // Controlled HTTP exact message: only accept exact id match.
797
+ const exact = await openCodeFetch(
798
+ `/session/${encodeURIComponent(sessionId)}/message/${encodeURIComponent(messageID)}`,
799
+ { directory },
800
+ ).catch((error) => {
801
+ const status = Number(error?.status ?? error?.statusCode);
802
+ const message = String(error?.message || '');
803
+ if (message.includes('404') || status === 404) return { __absent: true };
804
+ return { __try_projection: true };
805
+ });
806
+ if (exact && !exact.__absent && !exact.__try_projection) {
807
+ if (identityOf(exact) === messageID) return { status: 'found' };
808
+ }
809
+
810
+ // Authoritative projection of recent messages. Fetch failure → unavailable
811
+ // (never invent absent). Successful empty/miss → absent.
812
+ const projected = await fetchRecentMessages(sessionId, directory);
813
+ if (!projected) return { status: 'unavailable' };
814
+ if (projected.some((entry) => identityOf(entry) === messageID || entry?.info?.id === messageID)) {
815
+ return { status: 'found' };
816
+ }
817
+ return { status: 'absent' };
375
818
  };
376
819
 
377
820
  // Live session status is authoritative for whether the session is actually
@@ -384,6 +827,17 @@ export const createSessionGoalRuntime = ({
384
827
  // 'busy' | 'retry' | null (null when the status cannot be read — callers
385
828
  // fall back to the message-tail heuristic).
386
829
  const fetchSessionLiveStatus = async (sessionId, directory) => {
830
+ try {
831
+ const client = openCodeClient();
832
+ if (typeof client?.session?.active === 'function') {
833
+ const activeMap = await client.session.active(directoryRequestOptions(directory));
834
+ if (activeMap && typeof activeMap === 'object' && !Array.isArray(activeMap)) {
835
+ return Object.prototype.hasOwnProperty.call(activeMap, sessionId) ? 'busy' : 'idle';
836
+ }
837
+ }
838
+ } catch {
839
+ // Fall through.
840
+ }
387
841
  const statusMap = await openCodeFetch('/session/status', { directory }).catch(() => null);
388
842
  const status = statusMap?.[sessionId];
389
843
  const type = typeof status?.type === 'string' ? status.type.trim() : '';
@@ -408,19 +862,91 @@ export const createSessionGoalRuntime = ({
408
862
  return parseGoalMetadata(session);
409
863
  };
410
864
 
411
- const writeGoal = async (sessionId, directory, expectedGoalId, mutate) => {
865
+ /**
866
+ * Conditional goal commit. Validates goal id, optional execution generation,
867
+ * and optional allowed statuses on the same serial boundary when the Host
868
+ * mutate seam is wired. Returns the written goal or null when preconditions fail.
869
+ *
870
+ * @param {object} args
871
+ * @param {string} args.sessionId
872
+ * @param {string} args.directory
873
+ * @param {string} args.expectedGoalId
874
+ * @param {number} [args.expectedGeneration] when set, must match committed generation
875
+ * @param {string[]} [args.expectedStatuses] when set, status must be one of these
876
+ * @param {boolean} [args.advanceGeneration] bump executionGeneration on success
877
+ * @param {(current: object) => object} args.mutate partial fields to merge
878
+ */
879
+ const writeGoal = async (sessionId, directory, expectedGoalIdOrOptions, maybeMutate) => {
880
+ // Back-compat: writeGoal(sessionId, directory, goalId, mutate)
881
+ const options = typeof expectedGoalIdOrOptions === 'object' && expectedGoalIdOrOptions
882
+ ? expectedGoalIdOrOptions
883
+ : {
884
+ expectedGoalId: expectedGoalIdOrOptions,
885
+ mutate: maybeMutate,
886
+ };
887
+ const {
888
+ expectedGoalId,
889
+ expectedGeneration,
890
+ expectedStatuses = null,
891
+ advanceGeneration = false,
892
+ mutate,
893
+ } = options;
894
+ if (typeof mutate !== 'function' || typeof expectedGoalId !== 'string' || !expectedGoalId) {
895
+ return null;
896
+ }
897
+
898
+ const buildNextGoal = (currentGoal) => {
899
+ if (!currentGoal || currentGoal.id !== expectedGoalId) return null;
900
+ if (
901
+ expectedGeneration !== undefined
902
+ && readGoalExecutionGeneration(currentGoal) !== expectedGeneration
903
+ ) {
904
+ return null;
905
+ }
906
+ if (Array.isArray(expectedStatuses) && !expectedStatuses.includes(currentGoal.status)) {
907
+ return null;
908
+ }
909
+ const patch = mutate(currentGoal) || {};
910
+ let nextGoal = {
911
+ ...currentGoal,
912
+ ...patch,
913
+ updatedAt: Date.now(),
914
+ };
915
+ if (advanceGeneration) {
916
+ nextGoal.executionGeneration = nextGoalExecutionGeneration(currentGoal);
917
+ } else if (!Number.isFinite(nextGoal.executionGeneration)) {
918
+ nextGoal.executionGeneration = readGoalExecutionGeneration(currentGoal);
919
+ } else {
920
+ nextGoal.executionGeneration = readGoalExecutionGeneration(nextGoal);
921
+ }
922
+ return nextGoal;
923
+ };
924
+
925
+ if (typeof mutateSessionMetadata === 'function') {
926
+ const result = await mutateSessionMetadata(sessionId, (metadata) => {
927
+ const currentGoal = parseGoalMetadata({ metadata });
928
+ const nextGoal = buildNextGoal(currentGoal);
929
+ if (!nextGoal) {
930
+ return { ok: false, reason: 'goal_precondition_failed' };
931
+ }
932
+ return { ok: true, patch: { openchamber: { goal: nextGoal } } };
933
+ });
934
+ if (!result?.committed) return null;
935
+ return parseGoalMetadata({ metadata: result.metadata });
936
+ }
937
+
412
938
  if (isMetadataStoreWired()) {
413
939
  const currentGoal = await readGoal(sessionId, directory);
414
- if (!currentGoal || currentGoal.id !== expectedGoalId) return null;
415
- const nextGoal = { ...currentGoal, ...mutate(currentGoal), updatedAt: Date.now() };
940
+ const nextGoal = buildNextGoal(currentGoal);
941
+ if (!nextGoal) return null;
416
942
  await persistSessionGoal(sessionId, directory, nextGoal);
417
943
  return nextGoal;
418
944
  }
419
945
 
420
946
  const session = await openCodeFetch(`/session/${encodeURIComponent(sessionId)}`, { directory });
421
947
  const currentGoal = parseGoalMetadata(session);
422
- if (!currentGoal || currentGoal.id !== expectedGoalId) return null;
423
- const nextGoal = { ...currentGoal, ...mutate(currentGoal), updatedAt: Date.now() };
948
+ const nextGoal = buildNextGoal(currentGoal);
949
+ if (!nextGoal) return null;
424
950
  const currentMetadata = session?.metadata && typeof session.metadata === 'object' ? session.metadata : {};
425
951
  const currentNamespace = currentMetadata.openchamber && typeof currentMetadata.openchamber === 'object'
426
952
  ? currentMetadata.openchamber
@@ -438,18 +964,30 @@ export const createSessionGoalRuntime = ({
438
964
  return nextGoal;
439
965
  };
440
966
 
441
- const settleGoal = async ({ sessionId, directory, goal, status, statusReason, note, tokensUsed, tokensBaseline, tokensCommitted, lastAccountedMessageID }) => {
442
- const written = await writeGoal(sessionId, directory, goal.id, (current) => ({
443
- status,
444
- statusReason: clampText(statusReason, REASON_CHAR_LIMIT),
445
- note: note !== undefined ? clampText(note, NOTE_CHAR_LIMIT) : current.note,
446
- blockedStreak: 0,
447
- auditFailStreak: 0,
448
- ...(tokensUsed !== undefined ? { tokensUsed } : {}),
449
- ...(tokensBaseline !== undefined ? { tokensBaseline } : {}),
450
- ...(tokensCommitted !== undefined ? { tokensCommitted } : {}),
451
- ...(lastAccountedMessageID ? { lastAccountedMessageID } : {}),
452
- }));
967
+ const settleGoal = async ({ sessionId, directory, goal, status, statusReason, note, tokensUsed, tokensBaseline, tokensCommitted, lastAccountedMessageID, expectedGeneration, epochAtStart }) => {
968
+ if (epochAtStart !== undefined && currentDispatchEpoch(sessionId) !== epochAtStart) {
969
+ console.log(`[session-goal] ${sessionId} execution permit revoked before settle`);
970
+ return;
971
+ }
972
+ const generation = expectedGeneration !== undefined
973
+ ? expectedGeneration
974
+ : readGoalExecutionGeneration(goal);
975
+ const written = await writeGoal(sessionId, directory, {
976
+ expectedGoalId: goal.id,
977
+ expectedGeneration: generation,
978
+ expectedStatuses: ['active'],
979
+ mutate: (current) => ({
980
+ status,
981
+ statusReason: clampText(statusReason, REASON_CHAR_LIMIT),
982
+ note: note !== undefined ? clampText(note, NOTE_CHAR_LIMIT) : current.note,
983
+ blockedStreak: 0,
984
+ auditFailStreak: 0,
985
+ ...(tokensUsed !== undefined ? { tokensUsed } : {}),
986
+ ...(tokensBaseline !== undefined ? { tokensBaseline } : {}),
987
+ ...(tokensCommitted !== undefined ? { tokensCommitted } : {}),
988
+ ...(lastAccountedMessageID ? { lastAccountedMessageID } : {}),
989
+ }),
990
+ });
453
991
  if (!written) return;
454
992
  console.log(`[session-goal] ${sessionId} settled as ${status}${statusReason ? ` (${statusReason})` : ''}`);
455
993
  if (typeof emitGoalNotification === 'function') {
@@ -469,13 +1007,15 @@ export const createSessionGoalRuntime = ({
469
1007
  }
470
1008
  };
471
1009
 
472
- const runAudit = async ({ goal, assistantText, directory, lastAssistantInfo }) => {
1010
+ const runAudit = async ({ sessionId, goal, assistantText, directory, lastAssistantInfo, signal }) => {
1011
+ if (signal?.aborted) return null;
473
1012
  let service;
474
1013
  try {
475
1014
  service = await getSmallModelService();
476
1015
  } catch {
477
1016
  return null;
478
1017
  }
1018
+ if (signal?.aborted) return null;
479
1019
  const preferredProviderID = typeof lastAssistantInfo?.providerID === 'string' ? lastAssistantInfo.providerID : undefined;
480
1020
  const preferredModelID = typeof lastAssistantInfo?.modelID === 'string' ? lastAssistantInfo.modelID : undefined;
481
1021
  const prompt = `The goal objective:\n\n<objective>\n${goal.objective}\n</objective>\n\nThe agent's latest turn:\n\n${assistantText}\n\nReturn the verdict JSON. Write the note in the SAME language as this sample from the objective: "${goal.objective.slice(0, 200).replace(/\s+/g, ' ').trim()}"`;
@@ -491,65 +1031,499 @@ export const createSessionGoalRuntime = ({
491
1031
  }
492
1032
  return { verdict, note };
493
1033
  };
1034
+ const withAbort = async (work) => {
1035
+ if (!signal) return work();
1036
+ if (signal.aborted) {
1037
+ const abortError = new Error('goal audit aborted');
1038
+ abortError.name = 'AbortError';
1039
+ throw abortError;
1040
+ }
1041
+ return new Promise((resolve, reject) => {
1042
+ const onAbort = () => {
1043
+ const abortError = new Error('goal audit aborted');
1044
+ abortError.name = 'AbortError';
1045
+ reject(abortError);
1046
+ };
1047
+ signal.addEventListener('abort', onAbort, { once: true });
1048
+ work().then(
1049
+ (value) => {
1050
+ signal.removeEventListener('abort', onAbort);
1051
+ resolve(value);
1052
+ },
1053
+ (error) => {
1054
+ signal.removeEventListener('abort', onAbort);
1055
+ reject(error);
1056
+ },
1057
+ );
1058
+ });
1059
+ };
494
1060
  try {
495
1061
  // Prefer staying on the session provider. If that provider has no
496
1062
  // suitable small model (404), fall back to any authenticated small
497
1063
  // model so simple goals can still settle instead of stranding as
498
1064
  // "evaluating" then "blocked".
499
1065
  try {
500
- const generated = await service.generateSmallModelText({
1066
+ const generated = await withAbort(() => service.generateSmallModelText({
501
1067
  restrictToPreferredProvider: true,
502
1068
  prompt,
503
1069
  system,
504
1070
  directory,
505
1071
  preferredProviderID,
506
1072
  preferredModelID,
507
- });
1073
+ }));
1074
+ if (signal?.aborted) return null;
508
1075
  return parseAudit(generated);
509
1076
  } catch (restrictedError) {
1077
+ if (restrictedError?.name === 'AbortError' || signal?.aborted) return null;
510
1078
  if (Number(restrictedError?.statusCode) !== 404) throw restrictedError;
511
- const generated = await service.generateSmallModelText({
1079
+ const generated = await withAbort(() => service.generateSmallModelText({
512
1080
  restrictToPreferredProvider: false,
513
1081
  prompt,
514
1082
  system,
515
1083
  directory,
516
1084
  preferredProviderID,
517
1085
  preferredModelID,
518
- });
1086
+ }));
1087
+ if (signal?.aborted) return null;
519
1088
  return parseAudit(generated);
520
1089
  }
521
1090
  } catch (error) {
1091
+ if (error?.name === 'AbortError' || signal?.aborted) return null;
522
1092
  // No authenticated small model (404) or a transient failure — the loop
523
1093
  // still terminates via budget and the turn cap.
524
1094
  if (Number(error?.statusCode) !== 404) {
525
1095
  console.warn('[session-goal] audit failed:', error?.message || error);
526
1096
  }
527
1097
  return null;
1098
+ } finally {
1099
+ if (sessionId && auditControllers.get(sessionId)?.signal === signal) {
1100
+ auditControllers.delete(sessionId);
1101
+ }
1102
+ }
1103
+ };
1104
+
1105
+ /**
1106
+ * Local dispatch still permitted for this captured epoch?
1107
+ * Checked after every await and immediately before prompt leaves process.
1108
+ */
1109
+ const isContinuationDispatchLive = (sessionId, epoch) => {
1110
+ if (stopped) return false;
1111
+ if (currentDispatchEpoch(sessionId) !== epoch) return false;
1112
+ if (shutdownRecoveryPending.has(sessionId)) return false;
1113
+ return true;
1114
+ };
1115
+
1116
+ /**
1117
+ * Drop when epoch/shutdown/stopped moved (definite local revoke — not uncertain).
1118
+ * @returns {{ ok: false, identity: *, uncertain: false } | null}
1119
+ */
1120
+ const dropContinuationIfRevoked = (sessionId, epoch, dispatchIdentity, reason) => {
1121
+ if (isContinuationDispatchLive(sessionId, epoch)) return null;
1122
+ console.log(`[session-goal] ${reason}, dropping continuation`);
1123
+ return { ok: false, identity: dispatchIdentity, uncertain: false };
1124
+ };
1125
+
1126
+ /**
1127
+ * Durable generation/status still matches the committed dispatch identity.
1128
+ * Runs once at the prompt boundary after selection awaits.
1129
+ */
1130
+ const dispatchIdentityStillActive = async (sessionId, directory, dispatchIdentity) => {
1131
+ if (!dispatchIdentity) return true;
1132
+ try {
1133
+ const live = await readGoal(sessionId, directory);
1134
+ if (!live || live.id !== dispatchIdentity.goalId) return false;
1135
+ if (live.status !== 'active') return false;
1136
+ if (readGoalExecutionGeneration(live) !== dispatchIdentity.generation) return false;
1137
+ return true;
1138
+ } catch {
1139
+ // Metadata flake: fall back to epoch/shutdown only (caller re-checks).
1140
+ return true;
528
1141
  }
529
1142
  };
530
1143
 
531
- const sendContinuation = async ({ sessionId, directory, goal, lastAssistantInfo }) => {
532
- const providerID = typeof lastAssistantInfo?.providerID === 'string' ? lastAssistantInfo.providerID : '';
533
- const modelID = typeof lastAssistantInfo?.modelID === 'string' ? lastAssistantInfo.modelID : '';
1144
+ /**
1145
+ * Dispatch a continuation only while the captured dispatch epoch still matches.
1146
+ * Official v2 path: switchAgent/switchModel then `session.prompt` with stable
1147
+ * message `id` (msg_ + goalId + generation + turnsUsed). Selection awaits can
1148
+ * race with pause/shutdown — re-validate after each await and only mark
1149
+ * `phase: transport` at the actual prompt boundary. Selection failures must
1150
+ * not be reported as prompt-uncertain. Never claims revoke of in-flight prompt.
1151
+ *
1152
+ * `pending` carries the durable fixed payload (text + selection + messageID).
1153
+ * turnsUsed is NOT committed here — the caller commits turns + clears pending
1154
+ * only after accept (or after reconcile proves receipt).
1155
+ */
1156
+ const sendContinuation = async ({
1157
+ sessionId,
1158
+ directory,
1159
+ epoch,
1160
+ dispatchIdentity,
1161
+ pending,
1162
+ persistPendingPhase,
1163
+ }) => {
1164
+ const revoked = dropContinuationIfRevoked(sessionId, epoch, dispatchIdentity, 'dispatch epoch/shutdown at entry');
1165
+ if (revoked) return { ...revoked, phase: 'dropped' };
1166
+
1167
+ const providerID = pending?.providerID || '';
1168
+ const modelID = pending?.modelID || '';
1169
+ const variant = pending?.variant || '';
1170
+ const agent = pending?.agent || '';
534
1171
  if (!providerID || !modelID) {
535
1172
  throw new Error('cannot continue goal: last assistant message has no provider/model');
536
1173
  }
537
- const agent = typeof lastAssistantInfo?.agent === 'string' && lastAssistantInfo.agent
538
- ? lastAssistantInfo.agent
539
- : (typeof lastAssistantInfo?.mode === 'string' ? lastAssistantInfo.mode : '');
540
- const variant = typeof lastAssistantInfo?.variant === 'string' ? lastAssistantInfo.variant : '';
541
- await openCodeFetch(`/session/${encodeURIComponent(sessionId)}/prompt_async`, {
542
- directory,
543
- method: 'POST',
544
- body: {
545
- model: { providerID, modelID },
546
- ...(agent ? { agent } : {}),
547
- ...(variant ? { variant } : {}),
548
- // synthetic: hide the auto-continuation from the user transcript (same
549
- // convention as goal-intro / scheduled-task system parts).
550
- parts: [{ type: 'text', text: buildContinuationPrompt(goal), synthetic: true }],
1174
+ const messageID = pending?.messageID
1175
+ || (dispatchIdentity ? buildContinuationMessageID(dispatchIdentity) : undefined);
1176
+ const requestOptions = directoryRequestOptions(directory);
1177
+ const promptText = pending?.text || '';
1178
+ if (!promptText) {
1179
+ throw new Error('cannot continue goal: missing fixed continuation payload');
1180
+ }
1181
+ const promptBody = {
1182
+ ...(messageID ? { id: messageID } : {}),
1183
+ text: promptText,
1184
+ delivery: 'steer',
1185
+ metadata: {
1186
+ openchamber: {
1187
+ goalContinuation: true,
1188
+ // User-boundary prompt (raw type remains user); synthetic is metadata only.
1189
+ synthetic: true,
1190
+ ...(dispatchIdentity ? {
1191
+ goalId: dispatchIdentity.goalId,
1192
+ generation: dispatchIdentity.generation,
1193
+ turnsUsed: dispatchIdentity.turnsUsed,
1194
+ } : {}),
1195
+ },
551
1196
  },
552
- });
1197
+ };
1198
+
1199
+ /** True only after identity is recorded at the prompt transport boundary. */
1200
+ let enteredPromptTransport = false;
1201
+ const markLocal = (phase, error) => {
1202
+ if (!dispatchIdentity) return;
1203
+ dispatchedContinuations.set(sessionId, {
1204
+ ...dispatchIdentity,
1205
+ messageID: messageID || null,
1206
+ phase,
1207
+ ...(error ? { error: error?.message || String(error) } : {}),
1208
+ at: Date.now(),
1209
+ });
1210
+ };
1211
+ const markPromptTransport = async () => {
1212
+ markLocal('transport');
1213
+ enteredPromptTransport = true;
1214
+ if (typeof persistPendingPhase === 'function') {
1215
+ await persistPendingPhase('transport');
1216
+ }
1217
+ };
1218
+ const markPromptAccepted = () => {
1219
+ markLocal('accepted');
1220
+ };
1221
+ const markPromptUncertain = async (error) => {
1222
+ if (!dispatchIdentity || !enteredPromptTransport) return;
1223
+ markLocal('uncertain', error);
1224
+ if (typeof persistPendingPhase === 'function') {
1225
+ await persistPendingPhase('uncertain', error?.message || String(error));
1226
+ }
1227
+ };
1228
+
1229
+ try {
1230
+ const client = openCodeClient();
1231
+ if (typeof client?.session?.prompt === 'function') {
1232
+ if (typeof client.session.switchAgent === 'function' && agent) {
1233
+ await client.session.switchAgent({ sessionID: sessionId, agent }, requestOptions);
1234
+ const afterAgent = dropContinuationIfRevoked(
1235
+ sessionId, epoch, dispatchIdentity, 'dispatch revoked after switchAgent',
1236
+ );
1237
+ if (afterAgent) return { ...afterAgent, phase: 'dropped' };
1238
+ }
1239
+ if (typeof client.session.switchModel === 'function') {
1240
+ await client.session.switchModel({
1241
+ sessionID: sessionId,
1242
+ model: {
1243
+ id: modelID,
1244
+ providerID,
1245
+ ...(variant ? { variant } : {}),
1246
+ },
1247
+ }, requestOptions);
1248
+ const afterModel = dropContinuationIfRevoked(
1249
+ sessionId, epoch, dispatchIdentity, 'dispatch revoked after switchModel',
1250
+ );
1251
+ if (afterModel) return { ...afterModel, phase: 'dropped' };
1252
+ }
1253
+
1254
+ // Generation/status may have moved while selection awaited.
1255
+ if (!(await dispatchIdentityStillActive(sessionId, directory, dispatchIdentity))) {
1256
+ console.log('[session-goal] generation/status moved before prompt, dropping continuation');
1257
+ return { ok: false, identity: dispatchIdentity, uncertain: false, phase: 'dropped' };
1258
+ }
1259
+ const beforePrompt = dropContinuationIfRevoked(
1260
+ sessionId, epoch, dispatchIdentity, 'dispatch revoked before prompt',
1261
+ );
1262
+ if (beforePrompt) return { ...beforePrompt, phase: 'dropped' };
1263
+
1264
+ // Record identity only at the actual prompt boundary so selection
1265
+ // failure cannot look like prompt-uncertain, and pause during
1266
+ // switchAgent/switchModel does not claim transport began.
1267
+ await markPromptTransport();
1268
+ await client.session.prompt({
1269
+ sessionID: sessionId,
1270
+ ...promptBody,
1271
+ }, requestOptions);
1272
+ } else {
1273
+ // Controlled HTTP seam (tests / clients without SDK prompt): POST /prompt
1274
+ // with the same v2 body shape so id enters the payload.
1275
+ if (!(await dispatchIdentityStillActive(sessionId, directory, dispatchIdentity))) {
1276
+ console.log('[session-goal] generation/status moved before prompt, dropping continuation');
1277
+ return { ok: false, identity: dispatchIdentity, uncertain: false, phase: 'dropped' };
1278
+ }
1279
+ const beforePrompt = dropContinuationIfRevoked(
1280
+ sessionId, epoch, dispatchIdentity, 'dispatch revoked before prompt',
1281
+ );
1282
+ if (beforePrompt) return { ...beforePrompt, phase: 'dropped' };
1283
+
1284
+ await markPromptTransport();
1285
+ await openCodeFetch(`/session/${encodeURIComponent(sessionId)}/prompt`, {
1286
+ directory,
1287
+ method: 'POST',
1288
+ body: promptBody,
1289
+ });
1290
+ }
1291
+ markPromptAccepted();
1292
+ return { ok: true, identity: dispatchIdentity, uncertain: false, phase: 'accepted' };
1293
+ } catch (error) {
1294
+ // Only prompt-boundary failures are uncertain. switchAgent/switchModel
1295
+ // (selection) errors must not pretend a prompt may have been accepted.
1296
+ await markPromptUncertain(error);
1297
+ if (enteredPromptTransport) {
1298
+ return {
1299
+ ok: false,
1300
+ identity: dispatchIdentity,
1301
+ uncertain: true,
1302
+ phase: 'uncertain',
1303
+ error,
1304
+ };
1305
+ }
1306
+ throw error;
1307
+ }
1308
+ };
1309
+
1310
+ /**
1311
+ * Commit turnsUsed + clear pendingContinuation under the same conditional write
1312
+ * after upstream accept (or reconcile found). Returns written goal or null.
1313
+ */
1314
+ const commitContinuationAccepted = async ({
1315
+ sessionId,
1316
+ directory,
1317
+ expectedGoalId,
1318
+ expectedGeneration,
1319
+ expectedStatuses = ['active'],
1320
+ pendingTurnsUsed,
1321
+ tokensUsed,
1322
+ tokensBaseline,
1323
+ tokensCommitted,
1324
+ lastAccountedMessageID,
1325
+ blockedStreak,
1326
+ auditFailStreak,
1327
+ note,
1328
+ extraMutate,
1329
+ }) => writeGoal(sessionId, directory, {
1330
+ expectedGoalId,
1331
+ expectedGeneration,
1332
+ expectedStatuses,
1333
+ mutate: (current) => {
1334
+ const nextTurns = Math.max(
1335
+ Number.isFinite(current.turnsUsed) ? current.turnsUsed : 0,
1336
+ Number.isFinite(pendingTurnsUsed) ? pendingTurnsUsed : 0,
1337
+ );
1338
+ return {
1339
+ ...(tokensUsed !== undefined ? { tokensUsed } : {}),
1340
+ ...(tokensBaseline !== undefined ? { tokensBaseline } : {}),
1341
+ ...(tokensCommitted !== undefined ? { tokensCommitted } : {}),
1342
+ ...(lastAccountedMessageID ? { lastAccountedMessageID } : {}),
1343
+ ...(blockedStreak !== undefined ? { blockedStreak } : {}),
1344
+ ...(auditFailStreak !== undefined ? { auditFailStreak } : {}),
1345
+ ...(note !== undefined ? { note: clampText(note, NOTE_CHAR_LIMIT) } : {}),
1346
+ turnsUsed: nextTurns,
1347
+ pendingContinuation: null,
1348
+ statusReason: '',
1349
+ ...(typeof extraMutate === 'function' ? (extraMutate(current) || {}) : {}),
1350
+ };
1351
+ },
1352
+ });
1353
+
1354
+ /**
1355
+ * Clear a reserved (pre-transport) admission without counting a turn.
1356
+ * Used when selection fails or pause lands before prompt leaves process.
1357
+ * Generation is intentionally not required — pause may have advanced it.
1358
+ */
1359
+ const clearReservedContinuation = async ({
1360
+ sessionId,
1361
+ directory,
1362
+ expectedGoalId,
1363
+ messageID,
1364
+ }) => writeGoal(sessionId, directory, {
1365
+ expectedGoalId,
1366
+ // Only clear when pending still matches this reserved message id.
1367
+ mutate: (current) => {
1368
+ const pending = parsePendingContinuation(current.pendingContinuation);
1369
+ if (!pending || pending.messageID !== messageID) return {};
1370
+ if (pending.phase !== 'reserved') return {};
1371
+ return { pendingContinuation: null };
1372
+ },
1373
+ });
1374
+
1375
+ /**
1376
+ * Update durable pending phase (transport / uncertain) without bumping turns.
1377
+ */
1378
+ const updatePendingContinuationPhase = async ({
1379
+ sessionId,
1380
+ directory,
1381
+ expectedGoalId,
1382
+ expectedGeneration,
1383
+ messageID,
1384
+ phase,
1385
+ error = '',
1386
+ }) => writeGoal(sessionId, directory, {
1387
+ expectedGoalId,
1388
+ expectedGeneration,
1389
+ expectedStatuses: ['active'],
1390
+ mutate: (current) => {
1391
+ const pending = parsePendingContinuation(current.pendingContinuation);
1392
+ if (!pending || pending.messageID !== messageID) return {};
1393
+ return {
1394
+ pendingContinuation: buildPendingContinuation({
1395
+ ...pending,
1396
+ phase,
1397
+ error,
1398
+ at: Date.now(),
1399
+ }),
1400
+ };
1401
+ },
1402
+ });
1403
+
1404
+ /**
1405
+ * Ticket 10: reconcile durable pendingContinuation before any new dispatch.
1406
+ * found → count once + clear pending; absent + live gen → may re-dispatch;
1407
+ * unavailable → pause auto-continue with stable identity retained.
1408
+ *
1409
+ * @returns {'clear' | 'dispatch' | 'pause' | 'skip'}
1410
+ */
1411
+ const reconcilePendingContinuation = async ({
1412
+ sessionId,
1413
+ directory,
1414
+ goal,
1415
+ epochAtStart,
1416
+ }) => {
1417
+ const pending = parsePendingContinuation(goal.pendingContinuation);
1418
+ if (!pending) return 'clear';
1419
+
1420
+ // Stale admission from a prior goal id or superseded generation: drop without
1421
+ // counting. Never let old goal pending overwrite newer user operations.
1422
+ if (pending.goalId !== goal.id) {
1423
+ await writeGoal(sessionId, directory, {
1424
+ expectedGoalId: goal.id,
1425
+ expectedGeneration: readGoalExecutionGeneration(goal),
1426
+ mutate: () => ({ pendingContinuation: null }),
1427
+ });
1428
+ return 'clear';
1429
+ }
1430
+ if (pending.generation !== readGoalExecutionGeneration(goal)) {
1431
+ // Generation advanced (pause/resume/replace). If upstream already has the
1432
+ // message, count once under the current goal without re-dispatching.
1433
+ const lookup = await findContinuationMessage(sessionId, directory, pending.messageID);
1434
+ if (lookup.status === 'found') {
1435
+ await commitContinuationAccepted({
1436
+ sessionId,
1437
+ directory,
1438
+ expectedGoalId: goal.id,
1439
+ expectedGeneration: readGoalExecutionGeneration(goal),
1440
+ expectedStatuses: ['active', 'paused', 'blocked', 'budgetLimited', 'complete'],
1441
+ pendingTurnsUsed: pending.turnsUsed,
1442
+ });
1443
+ console.log(`[session-goal] ${sessionId} stale-gen pending found upstream — counted once`);
1444
+ return 'clear';
1445
+ }
1446
+ if (lookup.status === 'unavailable') {
1447
+ // Keep identity; do not invent absent. Pause only when still active.
1448
+ if (goal.status === 'active') {
1449
+ await writeGoal(sessionId, directory, {
1450
+ expectedGoalId: goal.id,
1451
+ expectedGeneration: readGoalExecutionGeneration(goal),
1452
+ expectedStatuses: ['active'],
1453
+ advanceGeneration: true,
1454
+ mutate: () => ({
1455
+ status: 'paused',
1456
+ statusReason: clampText(CONTINUATION_UNCERTAIN_REASON, REASON_CHAR_LIMIT),
1457
+ }),
1458
+ });
1459
+ revokeLocalExecutionPermit(sessionId);
1460
+ console.log(`[session-goal] ${sessionId} pending reconcile unavailable — paused`);
1461
+ return 'pause';
1462
+ }
1463
+ return 'skip';
1464
+ }
1465
+ // Absent under new generation: clear stale reserved work, never re-dispatch.
1466
+ await writeGoal(sessionId, directory, {
1467
+ expectedGoalId: goal.id,
1468
+ expectedGeneration: readGoalExecutionGeneration(goal),
1469
+ mutate: () => ({ pendingContinuation: null }),
1470
+ });
1471
+ return 'clear';
1472
+ }
1473
+
1474
+ const lookup = await findContinuationMessage(sessionId, directory, pending.messageID);
1475
+ if (lookup.status === 'found') {
1476
+ await commitContinuationAccepted({
1477
+ sessionId,
1478
+ directory,
1479
+ expectedGoalId: goal.id,
1480
+ expectedGeneration: readGoalExecutionGeneration(goal),
1481
+ expectedStatuses: goal.status === 'active'
1482
+ ? ['active']
1483
+ : ['active', 'paused', 'blocked', 'budgetLimited', 'complete'],
1484
+ pendingTurnsUsed: pending.turnsUsed,
1485
+ });
1486
+ console.log(`[session-goal] ${sessionId} pending continuation found — counted once`);
1487
+ return 'clear';
1488
+ }
1489
+ if (lookup.status === 'unavailable') {
1490
+ if (goal.status === 'active' && currentDispatchEpoch(sessionId) === epochAtStart) {
1491
+ await writeGoal(sessionId, directory, {
1492
+ expectedGoalId: goal.id,
1493
+ expectedGeneration: readGoalExecutionGeneration(goal),
1494
+ expectedStatuses: ['active'],
1495
+ advanceGeneration: true,
1496
+ mutate: () => ({
1497
+ status: 'paused',
1498
+ statusReason: clampText(CONTINUATION_UNCERTAIN_REASON, REASON_CHAR_LIMIT),
1499
+ // Keep pendingContinuation so reboot can retry reconcile with stable id.
1500
+ }),
1501
+ });
1502
+ revokeLocalExecutionPermit(sessionId);
1503
+ console.log(`[session-goal] ${sessionId} pending reconcile unavailable — paused with identity`);
1504
+ return 'pause';
1505
+ }
1506
+ return 'skip';
1507
+ }
1508
+
1509
+ // Absent: reserved/transport/uncertain may legally re-dispatch same id + payload.
1510
+ if (goal.status !== 'active') return 'skip';
1511
+ if (pending.phase === 'accepted') {
1512
+ // Accepted locally but not yet visible — treat as unavailable scope, not empty.
1513
+ await writeGoal(sessionId, directory, {
1514
+ expectedGoalId: goal.id,
1515
+ expectedGeneration: readGoalExecutionGeneration(goal),
1516
+ expectedStatuses: ['active'],
1517
+ advanceGeneration: true,
1518
+ mutate: () => ({
1519
+ status: 'paused',
1520
+ statusReason: clampText(CONTINUATION_UNCERTAIN_REASON, REASON_CHAR_LIMIT),
1521
+ }),
1522
+ });
1523
+ revokeLocalExecutionPermit(sessionId);
1524
+ return 'pause';
1525
+ }
1526
+ return 'dispatch';
553
1527
  };
554
1528
 
555
1529
  const tick = async (sessionId, directory) => {
@@ -561,6 +1535,10 @@ export const createSessionGoalRuntime = ({
561
1535
  return;
562
1536
  }
563
1537
 
1538
+ // Capture dispatch epoch before any await so a concurrent pause cannot
1539
+ // slip a continuation through after local permit revoke.
1540
+ const epochAtStart = currentDispatchEpoch(sessionId);
1541
+
564
1542
  const session = await openCodeFetch(`/session/${encodeURIComponent(sessionId)}`, { directory })
565
1543
  .catch((error) => {
566
1544
  console.warn(`[session-goal] session fetch failed: ${error?.message || error}`);
@@ -570,10 +1548,131 @@ export const createSessionGoalRuntime = ({
570
1548
  // Sub-agent/task sessions never carry user goals — skip them.
571
1549
  if (typeof session.parentID === 'string' && session.parentID) return;
572
1550
 
573
- const goal = isMetadataStoreWired()
1551
+ let goal = isMetadataStoreWired()
574
1552
  ? await readGoal(sessionId, directory)
575
1553
  : parseGoalMetadata(session);
576
1554
  if (!goal || goal.status !== 'active') return;
1555
+ if (currentDispatchEpoch(sessionId) !== epochAtStart) return;
1556
+
1557
+ // Shutdown recovery (ticket 07): keep auto-continue gated until an
1558
+ // authoritative idle/terminal clears the local recovery marker.
1559
+ if (shutdownRecoveryPending.has(sessionId)) {
1560
+ console.log(`[session-goal] ${sessionId} shutdown recovery pending, skip tick`);
1561
+ return;
1562
+ }
1563
+
1564
+ // Ticket 10: durable pending admission — reconcile before any new work.
1565
+ // found → turns counted once; unavailable → pause with stable identity;
1566
+ // dispatch → re-send same id + fixed payload (legal SDK idempotency).
1567
+ // Any pending handled this tick ends the tick (do not immediately open a
1568
+ // new admission slot after counting/re-dispatching the prior one).
1569
+ if (goal.pendingContinuation) {
1570
+ const pendingAction = await reconcilePendingContinuation({
1571
+ sessionId,
1572
+ directory,
1573
+ goal,
1574
+ epochAtStart,
1575
+ });
1576
+ if (pendingAction === 'pause' || pendingAction === 'skip' || pendingAction === 'clear') {
1577
+ // clear = counted once or dropped stale identity — wait for next idle.
1578
+ return;
1579
+ }
1580
+ // Re-read after reconcile may have mutated pending phase only.
1581
+ goal = isMetadataStoreWired()
1582
+ ? await readGoal(sessionId, directory)
1583
+ : parseGoalMetadata(session);
1584
+ if (!goal || goal.status !== 'active') return;
1585
+ if (currentDispatchEpoch(sessionId) !== epochAtStart) return;
1586
+
1587
+ if (pendingAction === 'dispatch' && goal.pendingContinuation) {
1588
+ const pending = goal.pendingContinuation;
1589
+ const dispatchIdentity = {
1590
+ goalId: pending.goalId,
1591
+ generation: pending.generation,
1592
+ turnsUsed: pending.turnsUsed,
1593
+ };
1594
+ const goalGeneration = readGoalExecutionGeneration(goal);
1595
+ const persistPendingPhase = async (phase, error) => {
1596
+ await updatePendingContinuationPhase({
1597
+ sessionId,
1598
+ directory,
1599
+ expectedGoalId: goal.id,
1600
+ expectedGeneration: goalGeneration,
1601
+ messageID: pending.messageID,
1602
+ phase,
1603
+ error,
1604
+ });
1605
+ };
1606
+ console.log(`[session-goal] ${sessionId} re-dispatching pending continuation ${pending.messageID}`);
1607
+ const result = await sendContinuation({
1608
+ sessionId,
1609
+ directory,
1610
+ epoch: epochAtStart,
1611
+ dispatchIdentity,
1612
+ pending,
1613
+ persistPendingPhase,
1614
+ });
1615
+ if (result?.ok) {
1616
+ const committed = await commitContinuationAccepted({
1617
+ sessionId,
1618
+ directory,
1619
+ expectedGoalId: goal.id,
1620
+ expectedGeneration: goalGeneration,
1621
+ pendingTurnsUsed: pending.turnsUsed,
1622
+ });
1623
+ if (!committed) {
1624
+ // Accept landed but durable commit failed — keep pending as accepted
1625
+ // so reboot reconcile can count once without blind new id.
1626
+ await writeGoal(sessionId, directory, {
1627
+ expectedGoalId: goal.id,
1628
+ expectedGeneration: goalGeneration,
1629
+ expectedStatuses: ['active'],
1630
+ mutate: (current) => {
1631
+ const live = parsePendingContinuation(current.pendingContinuation);
1632
+ if (!live || live.messageID !== pending.messageID) return {};
1633
+ return {
1634
+ pendingContinuation: buildPendingContinuation({
1635
+ ...live,
1636
+ phase: 'accepted',
1637
+ at: Date.now(),
1638
+ }),
1639
+ };
1640
+ },
1641
+ }).catch(() => null);
1642
+ console.warn(`[session-goal] ${sessionId} persist failure after accept — pending kept`);
1643
+ }
1644
+ } else if (result?.phase === 'dropped') {
1645
+ await clearReservedContinuation({
1646
+ sessionId,
1647
+ directory,
1648
+ expectedGoalId: goal.id,
1649
+ messageID: pending.messageID,
1650
+ });
1651
+ } else if (result?.uncertain) {
1652
+ // Durable phase already updated via persistPendingPhase; pause auto-continue.
1653
+ await writeGoal(sessionId, directory, {
1654
+ expectedGoalId: goal.id,
1655
+ expectedGeneration: goalGeneration,
1656
+ expectedStatuses: ['active'],
1657
+ advanceGeneration: true,
1658
+ mutate: () => ({
1659
+ status: 'paused',
1660
+ statusReason: clampText(CONTINUATION_UNCERTAIN_REASON, REASON_CHAR_LIMIT),
1661
+ }),
1662
+ });
1663
+ revokeLocalExecutionPermit(sessionId);
1664
+ }
1665
+ }
1666
+ return;
1667
+ }
1668
+
1669
+ const goalGeneration = readGoalExecutionGeneration(goal);
1670
+ const commitActive = (mutate) => writeGoal(sessionId, directory, {
1671
+ expectedGoalId: goal.id,
1672
+ expectedGeneration: goalGeneration,
1673
+ expectedStatuses: ['active'],
1674
+ mutate,
1675
+ });
577
1676
 
578
1677
  // File-backed objectives: the metadata carries only a flag; the objective
579
1678
  // TEXT lives under the OpenChamber data dir keyed by session id and is
@@ -598,7 +1697,7 @@ export const createSessionGoalRuntime = ({
598
1697
 
599
1698
  let lastAssistant = null;
600
1699
  for (let i = messages.length - 1; i >= 0; i -= 1) {
601
- if (messages[i]?.info?.role === 'assistant') {
1700
+ if (messageRole(messages[i]?.info) === 'assistant') {
602
1701
  lastAssistant = messages[i];
603
1702
  break;
604
1703
  }
@@ -613,7 +1712,7 @@ export const createSessionGoalRuntime = ({
613
1712
  let executionInfo = null;
614
1713
  for (let i = messages.length - 1; i >= 0; i -= 1) {
615
1714
  const info = messages[i]?.info;
616
- if (info?.role === 'assistant' && info.summary !== true) {
1715
+ if (messageRole(info) === 'assistant' && !isSummaryMessage(info)) {
617
1716
  executionInfo = info;
618
1717
  break;
619
1718
  }
@@ -631,7 +1730,7 @@ export const createSessionGoalRuntime = ({
631
1730
  // goal on "evaluating". Corroborate against the live session status before
632
1731
  // bailing — if the session is really idle, treat the orphan as a dead tail
633
1732
  // and let the loop resume; only a genuinely busy session bails here.
634
- if (lastMessageInfo?.role === 'user') return;
1733
+ if (messageRole(lastMessageInfo) === 'user') return;
635
1734
  if (lastAssistantInfo && !(lastAssistantInfo.time?.completed > 0) && !lastAssistantInfo.error) {
636
1735
  const live = await fetchSessionLiveStatus(sessionId, directory);
637
1736
  // live === 'idle' → restart orphan: resume past it. live === null →
@@ -669,14 +1768,21 @@ export const createSessionGoalRuntime = ({
669
1768
  // aborted reply is not evidence of anything).
670
1769
  const abortedTail = lastAssistantInfo.error?.name === 'MessageAbortedError';
671
1770
  if (abortedTail && goal.statusReason !== 'resumed') {
672
- await writeGoal(sessionId, directory, goal.id, () => ({
673
- status: 'paused',
674
- statusReason: 'paused after abort',
675
- tokensUsed,
676
- tokensBaseline,
677
- tokensCommitted,
678
- lastAccountedMessageID,
679
- }));
1771
+ revokeLocalExecutionPermit(sessionId);
1772
+ await writeGoal(sessionId, directory, {
1773
+ expectedGoalId: goal.id,
1774
+ expectedGeneration: goalGeneration,
1775
+ expectedStatuses: ['active'],
1776
+ advanceGeneration: true,
1777
+ mutate: () => ({
1778
+ status: 'paused',
1779
+ statusReason: 'paused after abort',
1780
+ tokensUsed,
1781
+ tokensBaseline,
1782
+ tokensCommitted,
1783
+ lastAccountedMessageID,
1784
+ }),
1785
+ });
680
1786
  console.log(`[session-goal] ${sessionId} paused after user abort`);
681
1787
  return;
682
1788
  }
@@ -687,7 +1793,7 @@ export const createSessionGoalRuntime = ({
687
1793
  ? lastAssistantInfo.error.name
688
1794
  : 'assistant turn failed';
689
1795
  await settleGoal({
690
- sessionId, directory, goal, status: 'blocked', statusReason: reason, tokensUsed, tokensBaseline, tokensCommitted, lastAccountedMessageID,
1796
+ sessionId, directory, goal, status: 'blocked', statusReason: reason, tokensUsed, tokensBaseline, tokensCommitted, lastAccountedMessageID, expectedGeneration: goalGeneration, epochAtStart,
691
1797
  });
692
1798
  return;
693
1799
  }
@@ -695,7 +1801,7 @@ export const createSessionGoalRuntime = ({
695
1801
  // Token budget crossed → budgetLimited.
696
1802
  if (typeof goal.tokenBudget === 'number' && tokensUsed >= goal.tokenBudget) {
697
1803
  await settleGoal({
698
- sessionId, directory, goal, status: 'budgetLimited', statusReason: 'token budget reached', tokensUsed, tokensBaseline, tokensCommitted, lastAccountedMessageID,
1804
+ sessionId, directory, goal, status: 'budgetLimited', statusReason: 'token budget reached', tokensUsed, tokensBaseline, tokensCommitted, lastAccountedMessageID, expectedGeneration: goalGeneration, epochAtStart,
699
1805
  });
700
1806
  return;
701
1807
  }
@@ -703,7 +1809,7 @@ export const createSessionGoalRuntime = ({
703
1809
  // Auto-continuation safety cap → blocked.
704
1810
  if (goal.turnsUsed >= maxAutoTurns) {
705
1811
  await settleGoal({
706
- sessionId, directory, goal, status: 'blocked', statusReason: 'auto-continuation limit reached', tokensUsed, tokensBaseline, tokensCommitted, lastAccountedMessageID,
1812
+ sessionId, directory, goal, status: 'blocked', statusReason: 'auto-continuation limit reached', tokensUsed, tokensBaseline, tokensCommitted, lastAccountedMessageID, expectedGeneration: goalGeneration, epochAtStart,
707
1813
  });
708
1814
  return;
709
1815
  }
@@ -718,16 +1824,42 @@ export const createSessionGoalRuntime = ({
718
1824
  let audit = null;
719
1825
  let blockedStreak = 0;
720
1826
  let auditFailStreak = goal.auditFailStreak;
721
- if (lastAssistantInfo.summary === true || abortedTail) {
1827
+ if (isSummaryMessage(lastAssistantInfo) || abortedTail) {
722
1828
  blockedStreak = goal.blockedStreak;
723
1829
  } else {
724
- audit = await runAudit({ goal: { ...goal, objective: effectiveObjective }, assistantText, directory, lastAssistantInfo: executionInfo ?? lastAssistantInfo });
1830
+ const auditController = new AbortController();
1831
+ auditControllers.set(sessionId, auditController);
1832
+ try {
1833
+ audit = await runAudit({
1834
+ sessionId,
1835
+ goal: { ...goal, objective: effectiveObjective },
1836
+ assistantText,
1837
+ directory,
1838
+ lastAssistantInfo: executionInfo ?? lastAssistantInfo,
1839
+ signal: auditController.signal,
1840
+ });
1841
+ } finally {
1842
+ if (auditControllers.get(sessionId) === auditController) {
1843
+ auditControllers.delete(sessionId);
1844
+ }
1845
+ }
1846
+
1847
+ // Pause/cancel during audit: drop late results — generation/epoch checks
1848
+ // below are the durable authority; this is the fast local path.
1849
+ if (currentDispatchEpoch(sessionId) !== epochAtStart) {
1850
+ console.log(`[session-goal] ${sessionId} execution permit revoked during audit, dropping result`);
1851
+ return;
1852
+ }
725
1853
 
726
1854
  // Audit unavailable: keep going under the hard turn/budget caps rather
727
1855
  // than flipping to "blocked" after a couple of 404s — that left simple
728
1856
  // completed goals stranded as evaluating→blocked when no small model
729
1857
  // was available on the session provider. Resume still re-audits.
730
1858
  if (!audit) {
1859
+ if (auditController.signal.aborted) {
1860
+ console.log(`[session-goal] ${sessionId} audit cancelled`);
1861
+ return;
1862
+ }
731
1863
  auditFailStreak += 1;
732
1864
  console.warn(`[session-goal] ${sessionId} audit unavailable, continuing under hard caps (${auditFailStreak} consecutive)`);
733
1865
  } else {
@@ -736,7 +1868,7 @@ export const createSessionGoalRuntime = ({
736
1868
 
737
1869
  if (audit?.verdict === 'complete') {
738
1870
  await settleGoal({
739
- sessionId, directory, goal, status: 'complete', statusReason: 'verified by audit', note: audit.note, tokensUsed, tokensBaseline, tokensCommitted, lastAccountedMessageID,
1871
+ sessionId, directory, goal, status: 'complete', statusReason: 'verified by audit', note: audit.note, tokensUsed, tokensBaseline, tokensCommitted, lastAccountedMessageID, expectedGeneration: goalGeneration, epochAtStart,
740
1872
  });
741
1873
  return;
742
1874
  }
@@ -745,28 +1877,144 @@ export const createSessionGoalRuntime = ({
745
1877
  blockedStreak = goal.blockedStreak + 1;
746
1878
  if (blockedStreak >= BLOCKED_STREAK_LIMIT) {
747
1879
  await settleGoal({
748
- sessionId, directory, goal, status: 'blocked', statusReason: audit.note || 'blocked per audit', note: audit.note, tokensUsed, tokensBaseline, tokensCommitted, lastAccountedMessageID,
1880
+ sessionId, directory, goal, status: 'blocked', statusReason: audit.note || 'blocked per audit', note: audit.note, tokensUsed, tokensBaseline, tokensCommitted, lastAccountedMessageID, expectedGeneration: goalGeneration, epochAtStart,
749
1881
  });
750
1882
  return;
751
1883
  }
752
1884
  }
753
1885
  }
754
1886
 
755
- // --- Continue: persist accounting first, then re-prompt ---
756
- // Order matters: if the write lands and the prompt fails, the goal just
757
- // waits for the next idle tick; the reverse could double-charge a turn.
758
- const written = await writeGoal(sessionId, directory, goal.id, (current) => ({
1887
+ if (currentDispatchEpoch(sessionId) !== epochAtStart) {
1888
+ console.log(`[session-goal] ${sessionId} execution permit revoked before continue commit`);
1889
+ return;
1890
+ }
1891
+
1892
+ // --- Continue (ticket 10 durable admission) ---
1893
+ // 1. Persist accounting + reserved pendingContinuation (fixed msg_id + payload)
1894
+ // WITHOUT bumping turnsUsed.
1895
+ // 2. Selection (switchAgent/switchModel); pause/fail → clear reserved, 0 count.
1896
+ // 3. Prompt boundary → phase transport; accept → turnsUsed + clear pending
1897
+ // in the same conditional commit; uncertain → durable phase + pause.
1898
+ const nextTurnsUsed = goal.turnsUsed + 1;
1899
+ const dispatchIdentity = {
1900
+ goalId: goal.id,
1901
+ generation: goalGeneration,
1902
+ turnsUsed: nextTurnsUsed,
1903
+ };
1904
+ const messageID = buildContinuationMessageID(dispatchIdentity);
1905
+ const execution = extractExecutionModel(executionInfo ?? lastAssistantInfo);
1906
+ if (!execution.providerID || !execution.modelID) {
1907
+ console.warn('[session-goal] cannot continue: no provider/model on last assistant');
1908
+ return;
1909
+ }
1910
+
1911
+ // Upstream already holds this fixed id (prior accept / reboot) → count once.
1912
+ const priorLookup = await findContinuationMessage(sessionId, directory, messageID);
1913
+ if (priorLookup.status === 'found') {
1914
+ await commitContinuationAccepted({
1915
+ sessionId,
1916
+ directory,
1917
+ expectedGoalId: goal.id,
1918
+ expectedGeneration: goalGeneration,
1919
+ pendingTurnsUsed: nextTurnsUsed,
1920
+ tokensUsed,
1921
+ tokensBaseline,
1922
+ tokensCommitted,
1923
+ lastAccountedMessageID,
1924
+ blockedStreak,
1925
+ auditFailStreak,
1926
+ note: audit?.note,
1927
+ });
1928
+ console.log(`[session-goal] ${sessionId} continuation ${messageID} already upstream — counted once`);
1929
+ return;
1930
+ }
1931
+ if (priorLookup.status === 'unavailable') {
1932
+ // Do not invent empty absence; pause with stable identity for later reconcile.
1933
+ const pendingOnly = buildPendingContinuation({
1934
+ messageID,
1935
+ goalId: goal.id,
1936
+ generation: goalGeneration,
1937
+ turnsUsed: nextTurnsUsed,
1938
+ phase: 'uncertain',
1939
+ text: buildContinuationPrompt({
1940
+ ...goal,
1941
+ objective: effectiveObjective,
1942
+ turnsUsed: nextTurnsUsed,
1943
+ }),
1944
+ providerID: execution.providerID,
1945
+ modelID: execution.modelID,
1946
+ variant: execution.variant,
1947
+ agent: execution.agent,
1948
+ error: 'pre-dispatch reconcile unavailable',
1949
+ });
1950
+ await writeGoal(sessionId, directory, {
1951
+ expectedGoalId: goal.id,
1952
+ expectedGeneration: goalGeneration,
1953
+ expectedStatuses: ['active'],
1954
+ advanceGeneration: true,
1955
+ mutate: () => ({
1956
+ status: 'paused',
1957
+ statusReason: clampText(CONTINUATION_UNCERTAIN_REASON, REASON_CHAR_LIMIT),
1958
+ tokensUsed,
1959
+ tokensBaseline,
1960
+ tokensCommitted,
1961
+ lastAccountedMessageID,
1962
+ blockedStreak,
1963
+ auditFailStreak,
1964
+ ...(audit?.note ? { note: clampText(audit.note, NOTE_CHAR_LIMIT) } : {}),
1965
+ pendingContinuation: pendingOnly,
1966
+ }),
1967
+ });
1968
+ revokeLocalExecutionPermit(sessionId);
1969
+ console.log(`[session-goal] ${sessionId} pre-dispatch reconcile unavailable — paused`);
1970
+ return;
1971
+ }
1972
+
1973
+ const priorDispatch = dispatchedContinuations.get(sessionId);
1974
+ if (
1975
+ priorDispatch
1976
+ && priorDispatch.goalId === dispatchIdentity.goalId
1977
+ && priorDispatch.generation === dispatchIdentity.generation
1978
+ && priorDispatch.turnsUsed === dispatchIdentity.turnsUsed
1979
+ && (priorDispatch.phase === 'transport' || priorDispatch.phase === 'accepted' || priorDispatch.phase === 'uncertain')
1980
+ ) {
1981
+ console.log('[session-goal] continuation identity already dispatched, skipping duplicate');
1982
+ return;
1983
+ }
1984
+
1985
+ const promptText = buildContinuationPrompt({
1986
+ ...goal,
1987
+ objective: effectiveObjective,
1988
+ turnsUsed: nextTurnsUsed,
1989
+ });
1990
+ const pending = buildPendingContinuation({
1991
+ messageID,
1992
+ goalId: goal.id,
1993
+ generation: goalGeneration,
1994
+ turnsUsed: nextTurnsUsed,
1995
+ phase: 'reserved',
1996
+ text: promptText,
1997
+ providerID: execution.providerID,
1998
+ modelID: execution.modelID,
1999
+ variant: execution.variant,
2000
+ agent: execution.agent,
2001
+ });
2002
+
2003
+ // Reserve durable identity before selection; turnsUsed stays at prior value.
2004
+ const reserved = await commitActive((current) => ({
759
2005
  tokensUsed,
760
2006
  tokensBaseline,
761
2007
  tokensCommitted,
762
2008
  lastAccountedMessageID,
763
- turnsUsed: current.turnsUsed + 1,
764
2009
  blockedStreak,
765
2010
  auditFailStreak,
766
2011
  statusReason: '',
767
2012
  ...(audit?.note ? { note: audit.note } : {}),
2013
+ pendingContinuation: pending,
2014
+ // Explicit: do not bump turnsUsed until accept/reconcile.
2015
+ turnsUsed: current.turnsUsed,
768
2016
  }));
769
- if (!written) {
2017
+ if (!reserved) {
770
2018
  console.log('[session-goal] goal changed during tick, dropping continuation');
771
2019
  return;
772
2020
  }
@@ -776,18 +2024,132 @@ export const createSessionGoalRuntime = ({
776
2024
  const latest = await fetchRecentMessages(sessionId, directory);
777
2025
  const latestLastInfo = latest && latest.length > 0 ? latest[latest.length - 1]?.info : null;
778
2026
  if (!latestLastInfo || latestLastInfo.id !== lastMessageInfo?.id) {
779
- console.log('[session-goal] tail moved on, dropping continuation');
2027
+ console.log('[session-goal] tail moved on, clearing reserved continuation');
2028
+ await clearReservedContinuation({
2029
+ sessionId,
2030
+ directory,
2031
+ expectedGoalId: goal.id,
2032
+ messageID,
2033
+ });
780
2034
  return;
781
2035
  }
782
2036
 
783
- // Re-check question blockers immediately before dispatch (uncertain included).
2037
+ // Re-check question blockers immediately before dispatch.
784
2038
  if (typeof isQuestionBlockingGoal === 'function' && isQuestionBlockingGoal(sessionId) === true) {
785
- console.log('[session-goal] question still blocking, dropping continuation');
2039
+ console.log('[session-goal] question still blocking, clearing reserved continuation');
2040
+ await clearReservedContinuation({
2041
+ sessionId,
2042
+ directory,
2043
+ expectedGoalId: goal.id,
2044
+ messageID,
2045
+ });
2046
+ return;
2047
+ }
2048
+
2049
+ // Pause confirmation (local epoch) after reserve must still block new delivery.
2050
+ if (currentDispatchEpoch(sessionId) !== epochAtStart) {
2051
+ console.log('[session-goal] paused after reserve, not dispatching (0 count)');
2052
+ await clearReservedContinuation({
2053
+ sessionId,
2054
+ directory,
2055
+ expectedGoalId: goal.id,
2056
+ messageID,
2057
+ });
786
2058
  return;
787
2059
  }
788
2060
 
789
- console.log(`[session-goal] continuing ${sessionId} (turn ${written.turnsUsed}/${maxAutoTurns}, tokens ${written.tokensUsed}${written.tokenBudget ? `/${written.tokenBudget}` : ''})`);
790
- await sendContinuation({ sessionId, directory, goal: { ...written, objective: effectiveObjective }, lastAssistantInfo: executionInfo ?? lastAssistantInfo });
2061
+ console.log(`[session-goal] continuing ${sessionId} (pending turn ${nextTurnsUsed}/${maxAutoTurns}, tokens ${reserved.tokensUsed}${reserved.tokenBudget ? `/${reserved.tokenBudget}` : ''}, gen ${goalGeneration})`);
2062
+
2063
+ const persistPendingPhase = async (phase, error) => {
2064
+ await updatePendingContinuationPhase({
2065
+ sessionId,
2066
+ directory,
2067
+ expectedGoalId: goal.id,
2068
+ messageID,
2069
+ phase,
2070
+ error,
2071
+ });
2072
+ };
2073
+
2074
+ let result;
2075
+ try {
2076
+ result = await sendContinuation({
2077
+ sessionId,
2078
+ directory,
2079
+ epoch: epochAtStart,
2080
+ dispatchIdentity,
2081
+ pending,
2082
+ persistPendingPhase,
2083
+ });
2084
+ } catch (error) {
2085
+ // Selection failure (pre-transport): clear reserved, 0 count.
2086
+ await clearReservedContinuation({
2087
+ sessionId,
2088
+ directory,
2089
+ expectedGoalId: goal.id,
2090
+ messageID,
2091
+ });
2092
+ console.warn('[session-goal] continuation selection failed:', error?.message || error);
2093
+ return;
2094
+ }
2095
+
2096
+ if (result?.ok) {
2097
+ const committed = await commitContinuationAccepted({
2098
+ sessionId,
2099
+ directory,
2100
+ expectedGoalId: goal.id,
2101
+ expectedGeneration: goalGeneration,
2102
+ pendingTurnsUsed: nextTurnsUsed,
2103
+ });
2104
+ if (!committed) {
2105
+ // Persist failure after accept: keep phase=accepted so reboot can count once.
2106
+ await writeGoal(sessionId, directory, {
2107
+ expectedGoalId: goal.id,
2108
+ expectedGeneration: goalGeneration,
2109
+ expectedStatuses: ['active'],
2110
+ mutate: (current) => {
2111
+ const live = parsePendingContinuation(current.pendingContinuation);
2112
+ if (!live || live.messageID !== messageID) return {};
2113
+ return {
2114
+ pendingContinuation: buildPendingContinuation({
2115
+ ...live,
2116
+ phase: 'accepted',
2117
+ at: Date.now(),
2118
+ }),
2119
+ };
2120
+ },
2121
+ }).catch(() => null);
2122
+ console.warn(`[session-goal] ${sessionId} persist failure after accept — pending kept as accepted`);
2123
+ }
2124
+ return;
2125
+ }
2126
+
2127
+ if (result?.phase === 'dropped') {
2128
+ // Selection-time pause/revoke: 0 count, clear reserved only.
2129
+ await clearReservedContinuation({
2130
+ sessionId,
2131
+ directory,
2132
+ expectedGoalId: goal.id,
2133
+ messageID,
2134
+ });
2135
+ return;
2136
+ }
2137
+
2138
+ if (result?.uncertain) {
2139
+ // Transport left process; durable uncertain phase already persisted.
2140
+ // Pause auto-continue with user-visible reason; keep stable identity.
2141
+ await writeGoal(sessionId, directory, {
2142
+ expectedGoalId: goal.id,
2143
+ expectedGeneration: goalGeneration,
2144
+ expectedStatuses: ['active'],
2145
+ advanceGeneration: true,
2146
+ mutate: () => ({
2147
+ status: 'paused',
2148
+ statusReason: clampText(CONTINUATION_UNCERTAIN_REASON, REASON_CHAR_LIMIT),
2149
+ }),
2150
+ });
2151
+ revokeLocalExecutionPermit(sessionId);
2152
+ }
791
2153
  };
792
2154
 
793
2155
  const armTimer = (sessionId, directory, quietMs) => {
@@ -868,25 +2230,70 @@ export const createSessionGoalRuntime = ({
868
2230
  // UI races: metadata may land as paused before the abort event. When the goal
869
2231
  // is already paused for a non-question reason, still notify so question
870
2232
  // auto-delegate timers are cancelled (abort path used to no-op on non-active).
871
- const pauseAfterAbort = async (sessionId, directory) => {
872
- const owner = await resolveGoalOwner(sessionId, directory);
2233
+ /**
2234
+ * Durable pause with generation advance + local permit revoke.
2235
+ * Shared by abort, question, and explicit user pause seams.
2236
+ */
2237
+ const pauseGoal = async (sessionId, directory, statusReason = 'paused by user', {
2238
+ notify = true,
2239
+ resolveOwner = true,
2240
+ } = {}) => {
2241
+ const owner = resolveOwner
2242
+ ? await resolveGoalOwner(sessionId, directory)
2243
+ : { sessionId, directory, session: null };
2244
+ if (!owner.sessionId) return null;
2245
+
2246
+ // Synchronous local revoke before any await on the durable write so a
2247
+ // concurrent tick cannot dispatch after pause is requested in-process.
2248
+ revokeLocalExecutionPermit(owner.sessionId);
2249
+
873
2250
  const goal = isMetadataStoreWired()
874
2251
  ? await readGoal(owner.sessionId, owner.directory)
875
- : parseGoalMetadata(owner.session);
876
- if (!goal) return;
2252
+ : parseGoalMetadata(owner.session ?? await openCodeFetch(
2253
+ `/session/${encodeURIComponent(owner.sessionId)}`,
2254
+ { directory: owner.directory },
2255
+ ).catch(() => null));
2256
+
2257
+ if (!goal) return null;
877
2258
  if (goal.status === 'paused') {
878
- if (!isQuestionDrivenPause(goal)) {
2259
+ if (notify && !isQuestionDrivenPause(goal)) {
879
2260
  notifyGoalPaused(owner.sessionId, owner.directory);
880
2261
  }
881
- return;
2262
+ return goal;
882
2263
  }
883
- if (goal.status !== 'active') return;
884
- await writeGoal(owner.sessionId, owner.directory, goal.id, () => ({
885
- status: 'paused',
886
- statusReason: 'paused after abort',
887
- }));
888
- console.log(`[session-goal] ${owner.sessionId} paused after user abort`);
889
- notifyGoalPaused(owner.sessionId, owner.directory);
2264
+ if (goal.status !== 'active') return null;
2265
+
2266
+ const written = await writeGoal(owner.sessionId, owner.directory, {
2267
+ expectedGoalId: goal.id,
2268
+ expectedGeneration: readGoalExecutionGeneration(goal),
2269
+ expectedStatuses: ['active'],
2270
+ advanceGeneration: true,
2271
+ mutate: (current) => {
2272
+ // Reserved (pre-transport) pending is not yet upstream — drop it so
2273
+ // pause→resume cannot re-dispatch old work. Transport/uncertain/accepted
2274
+ // keep stable identity for reconcile (do not claim upstream revoke).
2275
+ const pending = parsePendingContinuation(current.pendingContinuation);
2276
+ const clearReserved = pending && pending.phase === 'reserved'
2277
+ ? { pendingContinuation: null }
2278
+ : {};
2279
+ return {
2280
+ status: 'paused',
2281
+ statusReason: clampText(statusReason, REASON_CHAR_LIMIT),
2282
+ ...clearReserved,
2283
+ };
2284
+ },
2285
+ });
2286
+ if (written) {
2287
+ console.log(`[session-goal] ${owner.sessionId} paused (${statusReason}) gen=${written.executionGeneration}`);
2288
+ if (notify && !isQuestionDrivenPause(written)) {
2289
+ notifyGoalPaused(owner.sessionId, owner.directory);
2290
+ }
2291
+ }
2292
+ return written;
2293
+ };
2294
+
2295
+ const pauseAfterAbort = async (sessionId, directory) => {
2296
+ await pauseGoal(sessionId, directory, 'paused after abort', { notify: true });
890
2297
  };
891
2298
 
892
2299
  // Pause the active goal when the agent asks a question — without aborting
@@ -898,25 +2305,50 @@ export const createSessionGoalRuntime = ({
898
2305
  // record — owner.session may be null only if the session itself is missing.
899
2306
  if (!isMetadataStoreWired() && !owner.session) return;
900
2307
  if (isMetadataStoreWired() && !owner.sessionId) return;
901
- clearTimer(owner.sessionId);
902
- const goal = isMetadataStoreWired()
903
- ? await readGoal(owner.sessionId, owner.directory)
904
- : parseGoalMetadata(owner.session);
905
- if (!goal || goal.status !== 'active') return;
906
- await writeGoal(owner.sessionId, owner.directory, goal.id, () => ({
907
- status: 'paused',
908
- statusReason: 'paused for question',
909
- }));
910
- console.log(`[session-goal] ${owner.sessionId} paused for question`);
2308
+ await pauseGoal(owner.sessionId, owner.directory, 'paused for question', {
2309
+ notify: false,
2310
+ resolveOwner: false,
2311
+ });
911
2312
  // Takeover path already pauses questions; still notify for abort-style symmetry.
912
2313
  };
913
2314
 
914
2315
  const processPayload = (payload, directoryHint = '') => {
915
2316
  if (stopped) return;
916
2317
 
2318
+ const interrupted = extractExecutionInterrupted(payload);
2319
+ if (interrupted) {
2320
+ if (interrupted.reason === 'shutdown') {
2321
+ // Keep goal active but close the auto-continue gate until recovery.
2322
+ clearTimer(interrupted.sessionId);
2323
+ shutdownRecoveryPending.set(interrupted.sessionId, { at: Date.now() });
2324
+ console.log(`[session-goal] ${interrupted.sessionId} shutdown interrupt — recovery pending`);
2325
+ return;
2326
+ }
2327
+ // user / superseded / inactivity: claim released upstream — clear recovery.
2328
+ shutdownRecoveryPending.delete(interrupted.sessionId);
2329
+ if (interrupted.reason === 'user') {
2330
+ clearTimer(interrupted.sessionId);
2331
+ // MessageAbortedError path usually pauses; do not double-settle here.
2332
+ }
2333
+ return;
2334
+ }
2335
+
2336
+ if (
2337
+ payload?.type === 'session.execution.succeeded'
2338
+ || payload?.type === 'session.execution.failed'
2339
+ || payload?.type === 'session.execution.started'
2340
+ ) {
2341
+ const body = (payload.properties && typeof payload.properties === 'object')
2342
+ ? payload.properties
2343
+ : ((payload.data && typeof payload.data === 'object') ? payload.data : null);
2344
+ const sessionId = typeof body?.sessionID === 'string' ? body.sessionID.trim() : '';
2345
+ if (sessionId) shutdownRecoveryPending.delete(sessionId);
2346
+ }
2347
+
917
2348
  const aborted = extractAbortedAssistant(payload);
918
2349
  if (aborted) {
919
2350
  clearTimer(aborted.sessionId);
2351
+ shutdownRecoveryPending.delete(aborted.sessionId);
920
2352
  if (!inflight.has(aborted.sessionId)) {
921
2353
  inflight.add(aborted.sessionId);
922
2354
  pauseAfterAbort(aborted.sessionId, directoryHint)
@@ -954,6 +2386,8 @@ export const createSessionGoalRuntime = ({
954
2386
  const status = extractSessionStatus(payload);
955
2387
  if (status) {
956
2388
  if (status.type === 'idle') {
2389
+ // Authoritative idle after shutdown recovery reopens the continue gate.
2390
+ shutdownRecoveryPending.delete(status.sessionId);
957
2391
  armTimer(status.sessionId, status.directory || directoryHint, idleQuietMs);
958
2392
  } else {
959
2393
  clearTimer(status.sessionId);
@@ -964,11 +2398,12 @@ export const createSessionGoalRuntime = ({
964
2398
  // session.updated carries goal create/resume/pause without a status event.
965
2399
  const update = extractSessionUpdate(payload);
966
2400
  if (update && !update.parentID && update.goal) {
967
- // Explicit UI/user pause (not question-driven): cancel goal timer and
968
- // reverse-pause related question auto-delegate timers. Question pauses
969
- // keep auto-delegate counting so the agent can continue after auto-reply.
2401
+ // Explicit UI/user pause (not question-driven): revoke local execution
2402
+ // permit immediately so in-flight audits cannot dispatch, then notify.
2403
+ // Question pauses keep auto-delegate counting so the agent can continue
2404
+ // after auto-reply.
970
2405
  if (update.goal.status === 'paused') {
971
- clearTimer(update.sessionId);
2406
+ revokeLocalExecutionPermit(update.sessionId);
972
2407
  if (!isQuestionDrivenPause(update.goal)) {
973
2408
  notifyGoalPaused(update.sessionId, update.directory || directoryHint);
974
2409
  }
@@ -978,6 +2413,8 @@ export const createSessionGoalRuntime = ({
978
2413
  // Kickoff path: a goal set (or resumed — the UI stamps statusReason
979
2414
  // 'resumed') while the session is already idle. Arm a short timer; the
980
2415
  // tick's quiescence check keeps this safe if the session is actually busy.
2416
+ // Resume carries a new executionGeneration from the writer; old audits
2417
+ // fail the generation precondition on commit.
981
2418
  if (
982
2419
  update.goal.status === 'active'
983
2420
  && (update.goal.turnsUsed === 0 || update.goal.statusReason === 'resumed')
@@ -992,10 +2429,18 @@ export const createSessionGoalRuntime = ({
992
2429
 
993
2430
  const stop = () => {
994
2431
  stopped = true;
995
- for (const { timer } of timers.values()) {
996
- clearTimeout(timer);
2432
+ const sessionIds = new Set([
2433
+ ...timers.keys(),
2434
+ ...auditControllers.keys(),
2435
+ ...dispatchEpoch.keys(),
2436
+ ...shutdownRecoveryPending.keys(),
2437
+ ]);
2438
+ for (const sessionId of sessionIds) {
2439
+ revokeLocalExecutionPermit(sessionId);
997
2440
  }
998
2441
  timers.clear();
2442
+ auditControllers.clear();
2443
+ shutdownRecoveryPending.clear();
999
2444
  };
1000
2445
 
1001
2446
  return {
@@ -1003,5 +2448,22 @@ export const createSessionGoalRuntime = ({
1003
2448
  stop,
1004
2449
  /** Used by question auto-delegate user takeover / disable paths. */
1005
2450
  pauseForQuestion: (sessionId, directory) => pauseForQuestion(sessionId, directory),
2451
+ /**
2452
+ * Public pause seam (user pause / tests): durable status=paused + generation
2453
+ * advance + local dispatch revoke. Does not abort upstream turns — callers
2454
+ * use the existing stop operation for that.
2455
+ */
2456
+ pauseGoal: (sessionId, directory, statusReason = 'paused by user') => (
2457
+ pauseGoal(sessionId, directory, statusReason, { notify: true })
2458
+ ),
2459
+ /**
2460
+ * Public tick seam for tests and controlled async reproduction.
2461
+ * Runs one goal evaluation cycle immediately (no idle timer).
2462
+ */
2463
+ runTick: (sessionId, directory) => tick(sessionId, directory),
2464
+ /** Latest continuation dispatch identity for a session (test/reconcile). */
2465
+ getDispatchedContinuation: (sessionId) => dispatchedContinuations.get(sessionId) || null,
2466
+ /** Current local dispatch epoch (test seam). */
2467
+ getDispatchEpoch: (sessionId) => currentDispatchEpoch(sessionId),
1006
2468
  };
1007
2469
  };