@openchambery/web 1.19.22-beta.1 → 2.0.0-beta.3

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 (311) hide show
  1. package/README.md +13 -7
  2. package/bin/cli-opencode.test.js +208 -0
  3. package/bin/cli.js +62 -9
  4. package/bin/lib/cli-startup.js +105 -2
  5. package/bin/lib/cli-startup.test.js +179 -0
  6. package/dist/assets/{AgentAvatar-CRaBJXKs.js → AgentAvatar-B_JQoreg.js} +1 -1
  7. package/dist/assets/AssistantView-ClQAfGOT.js +3 -0
  8. package/dist/assets/{DiagramView-D1LGK_s2.js → DiagramView-BNnda8qO.js} +1 -1
  9. package/dist/assets/{FadeInOnReveal-B0kJo9Ii.js → FadeInOnReveal-VYQTSAkr.js} +1 -1
  10. package/dist/assets/{Icon-BgdfPhNs.js → Icon-BiEQEcih.js} +1 -1
  11. package/dist/assets/MarkdownRenderer-CJHSsWin.js +2 -0
  12. package/dist/assets/{MarkdownRendererImpl-DfoCJ1Mq.js → MarkdownRendererImpl-DwrHFGUp.js} +3 -3
  13. package/dist/assets/{MarkstreamRendererImpl-D2dsW5Lg.js → MarkstreamRendererImpl-CSnbQMu3.js} +1 -1
  14. package/dist/assets/MobileDetailNavigation-BXr6AGLT.js +1 -0
  15. package/dist/assets/{MobileShareBridge-2Yjn6wXg.js → MobileShareBridge-D7OE9OP_.js} +2 -2
  16. package/dist/assets/MobileSurface-D2jUkX9l.js +2 -0
  17. package/dist/assets/{MultiRunWindow-tTbMFowS.js → MultiRunWindow-DF3iIFCd.js} +1 -1
  18. package/dist/assets/{OnboardingScreen-CD2KtlF3.js → OnboardingScreen-BjHmlwlw.js} +1 -1
  19. package/dist/assets/OverlayScrollbar-DLtWO_Gp.js +2 -0
  20. package/dist/assets/SettingsView-BRxELjow.js +25 -0
  21. package/dist/assets/{SettingsWindow-zIa76s4t.js → SettingsWindow-BI5swspb.js} +1 -1
  22. package/dist/assets/{TerminalView-CDdzF4yi.js → TerminalView-BU3x8-fr.js} +1 -1
  23. package/dist/assets/{ToolOutputDialog-DNqA4uBl.js → ToolOutputDialog-BEWhBcRd.js} +2 -2
  24. package/dist/assets/appThemeRegistry-4MR0F7Jz.js +1 -0
  25. package/dist/assets/{button-CI7Q-Zw_.js → button-DKuz3L8l.js} +1 -1
  26. package/dist/assets/collapsible-DXbDynN4.js +3 -0
  27. package/dist/assets/context-menu-CqolsLCm.js +1 -0
  28. package/dist/assets/{desktopHosts-B1ixEylr.js → desktopHosts-CoDkOnyS.js} +1 -1
  29. package/dist/assets/{desktopRelayRestore-4JmFUw8r.js → desktopRelayRestore-DDlVKyOE.js} +1 -1
  30. package/dist/assets/{diagram-DdLO8HZ9.js → diagram-D7T8HQgj.js} +1 -1
  31. package/dist/assets/{dialog-B6pnIYmp.js → dialog-DQYl3W_o.js} +1 -1
  32. package/dist/assets/es-CN3E_zXj.js +15 -0
  33. package/dist/assets/{fileMentionTouchSelection-DNeiqns-.js → fileMentionTouchSelection-BZ6ovBYH.js} +1 -1
  34. package/dist/assets/{fileQueries-DQc8EJUf.js → fileQueries-JrpcsOCs.js} +1 -1
  35. package/dist/assets/fr-9zZvQZD2.js +13 -0
  36. package/dist/assets/gitApi-CM42qUed.js +10 -0
  37. package/dist/assets/{imageSource-BlAJJUEI.js → imageSource-CIl0FL10.js} +1 -1
  38. package/dist/assets/{input-D-__fwRi.js → input-CzQy8UhV.js} +1 -1
  39. package/dist/assets/insertionBoundaries-CtkZ9720.js +1 -0
  40. package/dist/assets/ja-Dnjk_82N.js +15 -0
  41. package/dist/assets/ko-BxCEE99C.js +15 -0
  42. package/dist/assets/main-BILgBI3B.js +169 -0
  43. package/dist/assets/main-BeveiW6F.js +2 -0
  44. package/dist/assets/markdown-worker-bS5OgGO3.js +2 -0
  45. package/dist/assets/{markdownHeightCache-BhxqJOvf.js → markdownHeightCache-Cj1I5NDw.js} +1 -1
  46. package/dist/assets/{markdownTheme-DOfGg-A7.js → markdownTheme-CKac3wKC.js} +1 -1
  47. package/dist/assets/mcpQueries-D5mQymgM.js +1 -0
  48. package/dist/assets/miniChat-BgrzM3u7.js +2 -0
  49. package/dist/assets/mobile-DtYoQE8C.js +2 -0
  50. package/dist/assets/multirun-jy8dEI96.js +4 -0
  51. package/dist/assets/{persistence-Ce3AnpEr.js → persistence-C9LQpiOS.js} +2 -2
  52. package/dist/assets/pl-CNNsWnPM.js +15 -0
  53. package/dist/assets/platform-BQghHRfp.js +6 -0
  54. package/dist/assets/pt-BR-BZB2DJMp.js +15 -0
  55. package/dist/assets/renderElectronMiniChatApp-BGrrmeME.js +2 -0
  56. package/dist/assets/renderMobileApp-u3c4GtFC.js +28 -0
  57. package/dist/assets/{runtimeConfig-CMkwoQ_X.js → runtimeConfig-BIGBRGQI.js} +1 -1
  58. package/dist/assets/{runtimeEndpointReset-ChOxt-J7.js → runtimeEndpointReset-D2savkuw.js} +12 -12
  59. package/dist/assets/{sessionChangeSummary-NnxcqCTI.js → sessionChangeSummary-eIbpSfrO.js} +1 -1
  60. package/dist/assets/{sessionLookup-BrU3rFHJ.js → sessionLookup-myHe5ltu.js} +1 -1
  61. package/dist/assets/{sortable-tabs-strip-Fnfhf3K9.js → sortable-tabs-strip-BQtmHx9r.js} +1 -1
  62. package/dist/assets/src-CLN7Z-zx.css +2 -0
  63. package/dist/assets/store-DLCiZaA6.js +15 -0
  64. package/dist/assets/{terminalApi-xjqssKke.js → terminalApi-BxcdiduX.js} +1 -1
  65. package/dist/assets/{textarea-bHYxZtfH.js → textarea-CakKvqFw.js} +1 -1
  66. package/dist/assets/{tooltip-0T5jv0cu.js → tooltip-Cx5gTbdY.js} +1 -1
  67. package/dist/assets/uk-rwQrxeYM.js +15 -0
  68. package/dist/assets/{url-_z7evzdK.js → url-CDHnjlsm.js} +1 -1
  69. package/dist/assets/{useAppFontEffects-C02YFyAv.js → useAppFontEffects-CNeo0aZs.js} +59 -45
  70. package/dist/assets/{useAssistantUIStore-CMutVh9B.js → useAssistantUIStore-asNG6K7n.js} +1 -1
  71. package/dist/assets/useDesktopWindowControlsLayout-DBJ56qIo.js +1 -0
  72. package/dist/assets/useEffectiveDirectory-B4LvR8Gt.js +1 -0
  73. package/dist/assets/{useFontPreferences-B1JYyzpK.js → useFontPreferences-CUnQ4TVm.js} +1 -1
  74. package/dist/assets/useMobileNavigationStore-ZHJPxlHt.js +414 -0
  75. package/dist/assets/{useRuntimeAPIs-DSLXJybW.js → useRuntimeAPIs-DwLtO0c_.js} +1 -1
  76. package/dist/assets/useSessionAutoCleanup-BIVmrnYU.js +2 -0
  77. package/dist/assets/{useThemeSystem-DMzWVJaW.js → useThemeSystem-BlNUWH47.js} +1 -1
  78. package/dist/assets/useUIStore-CQYKJnrg.js +1 -0
  79. package/dist/assets/{useWorkerHighlightedLines-C9z0ML1z.js → useWorkerHighlightedLines-CJZbJDW3.js} +2 -2
  80. package/dist/assets/{vendor-base-ui-CnHfUmDN.js → vendor-base-ui-CDjyofeD.js} +1 -1
  81. package/dist/assets/{vendor-cmdk-CeAGfrLo.js → vendor-cmdk-BFH0UpXX.js} +1 -1
  82. package/dist/assets/{vendor-dnd-kit-accessibility-BY5LhhUB.js → vendor-dnd-kit-accessibility-BrR3AKvM.js} +1 -1
  83. package/dist/assets/{vendor-dnd-kit-core-CvWxUPdb.js → vendor-dnd-kit-core-CNfdvk6H.js} +1 -1
  84. package/dist/assets/{vendor-dnd-kit-sortable-hHdP9lLb.js → vendor-dnd-kit-sortable-DQUaTeGj.js} +1 -1
  85. package/dist/assets/{vendor-framer-motion-DFy_BSVd.js → vendor-framer-motion-4dogsUk3.js} +1 -1
  86. package/dist/assets/{vendor-legendapp-list-WE32lwNk.js → vendor-legendapp-list-BU7MdPZR.js} +1 -1
  87. package/dist/assets/{vendor-markstream-react-DX55XLGO.js → vendor-markstream-react-qHC2FykU.js} +3 -3
  88. package/dist/assets/vendor-opencode-client-BOe2qpQb.js +12 -0
  89. package/dist/assets/{vendor-pierre-diffs-DX7XTPG3.js → vendor-pierre-diffs-CUfqC003.js} +1 -1
  90. package/dist/assets/{vendor-react-DLPElpoQ.js → vendor-react-C94A0AHl.js} +1 -1
  91. package/dist/assets/{vendor-react-drawio-ECtEwvbc.js → vendor-react-drawio-FVlWqKVE.js} +1 -1
  92. package/dist/assets/{vendor-reactuses-core-BnW6GxJt.js → vendor-reactuses-core-C2A3EjUi.js} +1 -1
  93. package/dist/assets/{vendor-sonner-DVzioF3d.js → vendor-sonner-DNqHmUQs.js} +1 -1
  94. package/dist/assets/{vendor-stream-diffs-DWy3xSmp.js → vendor-stream-diffs-CwbKsmlv.js} +2 -2
  95. package/dist/assets/{vendor-tanstack-react-query-D7pys_c9.js → vendor-tanstack-react-query-D0iFK57C.js} +1 -1
  96. package/dist/assets/{vendor-tanstack-react-virtual-tlDgH6PG.js → vendor-tanstack-react-virtual-B2kV6cnT.js} +1 -1
  97. package/dist/assets/{vendor-yaml-DIcAK3EC.js → vendor-yaml-C9KlJAlj.js} +37 -37
  98. package/dist/assets/{vendor-zustand-B4dlGEM2.js → vendor-zustand-BqLKf6ZY.js} +1 -1
  99. package/dist/assets/zh-CN-BvmBDnkD.js +15 -0
  100. package/dist/assets/zh-TW-9wcHTX0y.js +15 -0
  101. package/dist/index.html +13 -13
  102. package/dist/mini-chat.html +13 -13
  103. package/dist/mobile.html +13 -13
  104. package/package.json +2 -2
  105. package/server/index.js +392 -20
  106. package/server/lib/assistants/DOCUMENTATION.md +26 -21
  107. package/server/lib/assistants/assign.js +2 -1
  108. package/server/lib/assistants/contact-store.js +37 -19
  109. package/server/lib/assistants/contracts.js +11 -1
  110. package/server/lib/assistants/projection-history.test.js +454 -0
  111. package/server/lib/assistants/routes.js +11 -5
  112. package/server/lib/assistants/service.js +850 -294
  113. package/server/lib/assistants/service.test.js +588 -274
  114. package/server/lib/conversations/DOCUMENTATION.md +5 -5
  115. package/server/lib/conversations/service.js +87 -57
  116. package/server/lib/conversations/service.test.js +132 -100
  117. package/server/lib/conversations/validation.js +11 -1
  118. package/server/lib/event-stream/directory-ws-bridge.js +13 -2
  119. package/server/lib/event-stream/global-hub.js +51 -5
  120. package/server/lib/event-stream/runtime.js +57 -20
  121. package/server/lib/event-stream/runtime.test.js +112 -0
  122. package/server/lib/llm/DOCUMENTATION.md +95 -84
  123. package/server/lib/llm/catalog.js +111 -45
  124. package/server/lib/llm/catalog.test.js +121 -59
  125. package/server/lib/llm/completions.js +41 -12
  126. package/server/lib/llm/completions.test.js +45 -4
  127. package/server/lib/llm/generate.js +516 -453
  128. package/server/lib/llm/generate.test.js +705 -484
  129. package/server/lib/llm/routes.js +8 -4
  130. package/server/lib/message-queue/DOCUMENTATION.md +5 -5
  131. package/server/lib/message-queue/opencode-adapter.js +191 -104
  132. package/server/lib/message-queue/opencode-adapter.test.js +328 -58
  133. package/server/lib/message-queue/runtime.js +15 -2
  134. package/server/lib/message-queue/runtime.test.js +6 -6
  135. package/server/lib/message-queue/worker.js +7 -1
  136. package/server/lib/notifications/runtime.js +5 -0
  137. package/server/lib/opencode/DOCUMENTATION.md +103 -16
  138. package/server/lib/opencode/agent-document.d.ts +28 -0
  139. package/server/lib/opencode/agent-document.js +280 -0
  140. package/server/lib/opencode/agent-document.test.js +137 -0
  141. package/server/lib/opencode/agents.js +96 -0
  142. package/server/lib/opencode/auth-state-runtime.js +5 -0
  143. package/server/lib/opencode/bootstrap-runtime.js +2 -0
  144. package/server/lib/opencode/config-entity-routes.js +111 -102
  145. package/server/lib/opencode/config-entity-routes.test.js +123 -10
  146. package/server/lib/opencode/config-hot-reload.integration.test.js +105 -0
  147. package/server/lib/opencode/ensure-cli.js +238 -0
  148. package/server/lib/opencode/ensure-cli.test.js +119 -0
  149. package/server/lib/opencode/env-runtime.js +208 -111
  150. package/server/lib/opencode/env-runtime.test.js +211 -14
  151. package/server/lib/opencode/feature-routes-runtime.js +80 -5
  152. package/server/lib/opencode/feature-routes-runtime.test.js +18 -0
  153. package/server/lib/opencode/http-server-connections.js +98 -0
  154. package/server/lib/opencode/http-server-connections.test.js +321 -0
  155. package/server/lib/opencode/lifecycle.js +539 -95
  156. package/server/lib/opencode/lifecycle.test.js +698 -6
  157. package/server/lib/opencode/managed-capabilities-runtime.js +15 -2
  158. package/server/lib/opencode/managed-capabilities-runtime.test.js +1 -0
  159. package/server/lib/opencode/network-runtime.js +28 -16
  160. package/server/lib/opencode/network-runtime.test.js +68 -4
  161. package/server/lib/opencode/openchamber-routes.js +7 -1
  162. package/server/lib/opencode/opencode2-pin.js +139 -0
  163. package/server/lib/opencode/opencode2-pin.test.js +88 -0
  164. package/server/lib/opencode/opencode2-sidecar.e2e.test.js +326 -0
  165. package/server/lib/opencode/owned-runtime-upgrade.js +431 -0
  166. package/server/lib/opencode/owned-runtime-upgrade.test.js +160 -0
  167. package/server/lib/opencode/plugin-compatibility.js +15 -0
  168. package/server/lib/opencode/plugin-compatibility.test.js +21 -0
  169. package/server/lib/opencode/plugin-routes.js +3 -18
  170. package/server/lib/opencode/plugin-routes.test.js +9 -9
  171. package/server/lib/opencode/plugins.js +4 -3
  172. package/server/lib/opencode/plugins.test.js +12 -0
  173. package/server/lib/opencode/provider-catalog.js +58 -12
  174. package/server/lib/opencode/provider-catalog.test.js +48 -0
  175. package/server/lib/opencode/proxy.js +368 -28
  176. package/server/lib/opencode/proxy.test.js +338 -0
  177. package/server/lib/opencode/routes.js +303 -76
  178. package/server/lib/opencode/routes.test.js +315 -50
  179. package/server/lib/opencode/runtime-contract.js +401 -0
  180. package/server/lib/opencode/runtime-contract.test.js +214 -0
  181. package/server/lib/opencode/server-opencode-fetch.js +242 -0
  182. package/server/lib/opencode/server-opencode-fetch.test.js +130 -0
  183. package/server/lib/opencode/server-utils-runtime.js +35 -0
  184. package/server/lib/opencode/shared-service.integration.test.js +66 -0
  185. package/server/lib/opencode/shared-service.js +147 -0
  186. package/server/lib/opencode/shared-service.test.js +93 -0
  187. package/server/lib/opencode/shared.js +3 -3
  188. package/server/lib/opencode/shutdown-runtime.js +15 -6
  189. package/server/lib/opencode/shutdown-runtime.test.js +49 -1
  190. package/server/lib/opencode/skill-routes.js +26 -40
  191. package/server/lib/opencode/skill-routes.test.js +71 -7
  192. package/server/lib/opencode/skills.test.js +15 -0
  193. package/server/lib/opencode/startup-pipeline-runtime.js +4 -0
  194. package/server/lib/opencode/static-routes-runtime.js +3 -1
  195. package/server/lib/opencode/static-routes-runtime.test.js +33 -0
  196. package/server/lib/opencode/v1-migration-gate.js +157 -0
  197. package/server/lib/opencode/v1-migration-gate.test.js +111 -0
  198. package/server/lib/opencode/v2-client.js +60 -0
  199. package/server/lib/opencode/v2-plugin-host-shim/index.mjs +155 -0
  200. package/server/lib/opencode/v2-plugin-host-shim/package.json +4 -0
  201. package/server/lib/opencode/watcher.js +2 -1
  202. package/server/lib/opencode/watcher.test.js +1 -1
  203. package/server/lib/permission-auto-accept/DOCUMENTATION.md +5 -5
  204. package/server/lib/permission-auto-accept/runtime.js +14 -7
  205. package/server/lib/permission-auto-accept/runtime.test.js +27 -12
  206. package/server/lib/relay/reasoning-projection.e2e.test.ts +22 -7
  207. package/server/lib/scheduled-tasks/DOCUMENTATION.md +79 -39
  208. package/server/lib/scheduled-tasks/managed-tool-route.js +31 -1
  209. package/server/lib/scheduled-tasks/managed-tool-route.test.js +23 -0
  210. package/server/lib/scheduled-tasks/routes.js +27 -0
  211. package/server/lib/scheduled-tasks/routes.test.js +94 -0
  212. package/server/lib/scheduled-tasks/runtime.js +381 -310
  213. package/server/lib/scheduled-tasks/runtime.test.js +600 -309
  214. package/server/lib/session-goal/DOCUMENTATION.md +173 -22
  215. package/server/lib/session-goal/capability.js +57 -0
  216. package/server/lib/session-goal/capability.test.js +33 -0
  217. package/server/lib/session-goal/routes.js +240 -2
  218. package/server/lib/session-goal/routes.test.js +211 -0
  219. package/server/lib/session-goal/runtime.js +1617 -113
  220. package/server/lib/session-goal/runtime.test.js +1500 -70
  221. package/server/lib/session-index/DOCUMENTATION.md +18 -15
  222. package/server/lib/session-index/event-ingest.js +89 -16
  223. package/server/lib/session-index/event-ingest.test.js +73 -0
  224. package/server/lib/session-index/host-authority-snapshot.js +92 -0
  225. package/server/lib/session-index/routes.host-archive.test.js +201 -0
  226. package/server/lib/session-index/routes.js +63 -10
  227. package/server/lib/session-index/routes.test.js +8 -8
  228. package/server/lib/session-index/service.js +7 -1
  229. package/server/lib/session-index/sync-runtime.js +171 -41
  230. package/server/lib/session-index/sync-runtime.test.js +164 -9
  231. package/server/lib/session-metadata/DOCUMENTATION.md +86 -0
  232. package/server/lib/session-metadata/routes.js +193 -0
  233. package/server/lib/session-metadata/routes.test.js +156 -0
  234. package/server/lib/session-metadata/session-archive.d.ts +73 -0
  235. package/server/lib/session-metadata/session-archive.js +654 -0
  236. package/server/lib/session-metadata/session-archive.test.js +359 -0
  237. package/server/lib/session-metadata/session-metadata-store.d.ts +36 -0
  238. package/server/lib/session-metadata/session-metadata-store.js +375 -0
  239. package/server/lib/session-metadata/session-metadata-store.test.js +379 -0
  240. package/server/lib/session-metadata/session-projection.d.ts +41 -0
  241. package/server/lib/session-metadata/session-projection.js +255 -0
  242. package/server/lib/session-metadata/session-projection.test.js +64 -0
  243. package/server/lib/session-metadata/system-session.js +55 -0
  244. package/server/lib/session-metadata/system-session.test.js +55 -0
  245. package/server/lib/session-title/DOCUMENTATION.md +40 -7
  246. package/server/lib/session-title/native-runtime.test.js +167 -0
  247. package/server/lib/session-title/runtime.js +67 -42
  248. package/server/lib/session-title/session-access.js +46 -0
  249. package/server/lib/session-title/session-access.test.js +91 -0
  250. package/server/lib/session-turn-pages/DOCUMENTATION.md +12 -11
  251. package/server/lib/session-turn-pages/reconcile.service.js +1 -1
  252. package/server/lib/session-turn-pages/reconcile.service.test.js +16 -0
  253. package/server/lib/session-turn-pages/routes.js +123 -75
  254. package/server/lib/session-turn-pages/routes.test.js +566 -0
  255. package/server/lib/session-turn-pages/service.js +6 -2
  256. package/server/lib/session-turn-pages/service.test.js +34 -0
  257. package/server/lib/session-turn-pages/session-message-projection.d.ts +13 -0
  258. package/server/lib/session-turn-pages/session-message-projection.js +281 -0
  259. package/server/lib/small-model/DOCUMENTATION.md +101 -146
  260. package/server/lib/small-model/catalog.js +84 -54
  261. package/server/lib/small-model/catalog.test.js +111 -9
  262. package/server/lib/small-model/custom-api.js +68 -0
  263. package/server/lib/small-model/custom-api.test.js +64 -0
  264. package/server/lib/small-model/index.js +125 -241
  265. package/server/lib/small-model/resolve.js +35 -120
  266. package/server/lib/small-model/resolve.test.js +20 -110
  267. package/server/lib/small-model/routes.js +1 -3
  268. package/server/lib/small-model/summary-settings.test.js +157 -253
  269. package/server/opencode-proxy.test.js +63 -12
  270. package/dist/assets/AssistantView-BUveAgc4.js +0 -3
  271. package/dist/assets/MarkdownRenderer-BbrbDH6-.js +0 -2
  272. package/dist/assets/MobileDetailNavigation-B0aqKXd7.js +0 -1
  273. package/dist/assets/MobileSurface-DW7WfPsy.js +0 -2
  274. package/dist/assets/OverlayScrollbar-DrA0Vsg5.js +0 -2
  275. package/dist/assets/SettingsView-PkXIavr7.js +0 -25
  276. package/dist/assets/appThemeRegistry-D5Yxg4YF.js +0 -1
  277. package/dist/assets/collapsible-D5Phaykh.js +0 -1
  278. package/dist/assets/context-menu-CubSDW2g.js +0 -1
  279. package/dist/assets/es-DA3MqOo0.js +0 -15
  280. package/dist/assets/fr-Dbz0CdIA.js +0 -13
  281. package/dist/assets/gitApi-DXyC4MiV.js +0 -7
  282. package/dist/assets/insertionBoundaries-KlOVrHHt.js +0 -1
  283. package/dist/assets/ja-DumanVQA.js +0 -15
  284. package/dist/assets/ko-C0IkiUjQ.js +0 -15
  285. package/dist/assets/main-BPS5ylib.js +0 -2
  286. package/dist/assets/main-Cpd9VWYs.js +0 -169
  287. package/dist/assets/markdown-worker-CptoICmq.js +0 -2
  288. package/dist/assets/miniChat-B8_KLWiY.js +0 -2
  289. package/dist/assets/mobile-CtktKrXn.js +0 -2
  290. package/dist/assets/multirun-CYfP9Cfm.js +0 -4
  291. package/dist/assets/pl-B9No6MQp.js +0 -15
  292. package/dist/assets/platform-0w_Q9_ji.js +0 -2
  293. package/dist/assets/projectMeta-S1hJ7oBp.js +0 -3
  294. package/dist/assets/pt-BR-DyQ0WTDh.js +0 -15
  295. package/dist/assets/renderElectronMiniChatApp-iK14LWm8.js +0 -2
  296. package/dist/assets/renderMobileApp-DORLPWnb.js +0 -28
  297. package/dist/assets/src-rS3zCcsC.css +0 -2
  298. package/dist/assets/store-5B_LDnLM.js +0 -15
  299. package/dist/assets/uk-C_1KkqyP.js +0 -15
  300. package/dist/assets/useDesktopWindowControlsLayout-Dnr-jX0C.js +0 -1
  301. package/dist/assets/useEffectiveDirectory-CZLLyarc.js +0 -1
  302. package/dist/assets/useMobileNavigationStore-CoCiqNgm.js +0 -413
  303. package/dist/assets/useSessionAutoCleanup-xQyETQkG.js +0 -2
  304. package/dist/assets/useUIStore-Bxv3f23A.js +0 -1
  305. package/dist/assets/vendor-opencode-sdk-C07HQypa.js +0 -7
  306. package/dist/assets/zh-CN-uWILgBdt.js +0 -15
  307. package/dist/assets/zh-TW-MYfdvhbD.js +0 -15
  308. package/server/lib/small-model/call.js +0 -427
  309. package/server/lib/small-model/call.test.js +0 -382
  310. package/server/lib/small-model/opencode-session.js +0 -339
  311. package/server/lib/small-model/opencode-session.test.js +0 -177
@@ -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,
@@ -319,11 +588,67 @@ export const createSessionGoalRuntime = ({
319
588
  * timers for the session tree (root + descendants).
320
589
  */
321
590
  onGoalPaused,
591
+ /**
592
+ * OpenChamber session-metadata store seams. When both are functions, goal
593
+ * state is read/written only through the Host store (OpenCode 2.x has no
594
+ * session-metadata PATCH). When either is missing, keep the legacy OpenCode
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.
600
+ */
601
+ persistSessionGoal = null,
602
+ readSessionMetadata = null,
603
+ mutateSessionMetadata = null,
322
604
  }) => {
323
605
  const timers = new Map();
324
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();
325
625
  let stopped = false;
326
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
+
644
+ const isMetadataStoreWired = () => (
645
+ typeof readSessionMetadata === 'function'
646
+ && (
647
+ typeof mutateSessionMetadata === 'function'
648
+ || typeof persistSessionGoal === 'function'
649
+ )
650
+ );
651
+
327
652
  const clearTimer = (sessionId) => {
328
653
  const existing = timers.get(sessionId);
329
654
  if (existing) {
@@ -332,7 +657,39 @@ export const createSessionGoalRuntime = ({
332
657
  }
333
658
  };
334
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
+
335
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
+ }
336
693
  const base = buildOpenCodeUrl(fetchPath, '');
337
694
  const params = new URLSearchParams(query || {});
338
695
  if (directory) params.set('directory', directory);
@@ -354,12 +711,110 @@ export const createSessionGoalRuntime = ({
354
711
  return response.json().catch(() => null);
355
712
  };
356
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
+ */
357
720
  const fetchRecentMessages = async (sessionId, directory) => {
358
- 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`, {
359
735
  directory,
360
736
  query: { limit: String(MESSAGE_FETCH_LIMIT) },
361
737
  }).catch(() => null);
362
- 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' };
363
818
  };
364
819
 
365
820
  // Live session status is authoritative for whether the session is actually
@@ -372,6 +827,17 @@ export const createSessionGoalRuntime = ({
372
827
  // 'busy' | 'retry' | null (null when the status cannot be read — callers
373
828
  // fall back to the message-tail heuristic).
374
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
+ }
375
841
  const statusMap = await openCodeFetch('/session/status', { directory }).catch(() => null);
376
842
  const status = statusMap?.[sessionId];
377
843
  const type = typeof status?.type === 'string' ? status.type.trim() : '';
@@ -383,11 +849,104 @@ export const createSessionGoalRuntime = ({
383
849
  // metadata writes (assist payloads, dismissals, UI goal edits) survive.
384
850
  // Returns the written goal, or null when the stored goal no longer matches
385
851
  // the expected id (user replaced/cleared it while we worked).
386
- const writeGoal = async (sessionId, directory, expectedGoalId, mutate) => {
852
+ /**
853
+ * When the Host metadata store is wired, the goal lives there — OpenCode 2.x
854
+ * accepts session metadata only at create time. Re-read before every write so
855
+ * a concurrent edit (the user pausing from the UI) is not overwritten.
856
+ */
857
+ const readGoal = async (sessionId, directory) => {
858
+ if (isMetadataStoreWired()) {
859
+ return parseGoalMetadata({ metadata: await readSessionMetadata(sessionId) });
860
+ }
861
+ const session = await openCodeFetch(`/session/${encodeURIComponent(sessionId)}`, { directory });
862
+ return parseGoalMetadata(session);
863
+ };
864
+
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
+
938
+ if (isMetadataStoreWired()) {
939
+ const currentGoal = await readGoal(sessionId, directory);
940
+ const nextGoal = buildNextGoal(currentGoal);
941
+ if (!nextGoal) return null;
942
+ await persistSessionGoal(sessionId, directory, nextGoal);
943
+ return nextGoal;
944
+ }
945
+
387
946
  const session = await openCodeFetch(`/session/${encodeURIComponent(sessionId)}`, { directory });
388
947
  const currentGoal = parseGoalMetadata(session);
389
- if (!currentGoal || currentGoal.id !== expectedGoalId) return null;
390
- const nextGoal = { ...currentGoal, ...mutate(currentGoal), updatedAt: Date.now() };
948
+ const nextGoal = buildNextGoal(currentGoal);
949
+ if (!nextGoal) return null;
391
950
  const currentMetadata = session?.metadata && typeof session.metadata === 'object' ? session.metadata : {};
392
951
  const currentNamespace = currentMetadata.openchamber && typeof currentMetadata.openchamber === 'object'
393
952
  ? currentMetadata.openchamber
@@ -405,18 +964,30 @@ export const createSessionGoalRuntime = ({
405
964
  return nextGoal;
406
965
  };
407
966
 
408
- const settleGoal = async ({ sessionId, directory, goal, status, statusReason, note, tokensUsed, tokensBaseline, tokensCommitted, lastAccountedMessageID }) => {
409
- const written = await writeGoal(sessionId, directory, goal.id, (current) => ({
410
- status,
411
- statusReason: clampText(statusReason, REASON_CHAR_LIMIT),
412
- note: note !== undefined ? clampText(note, NOTE_CHAR_LIMIT) : current.note,
413
- blockedStreak: 0,
414
- auditFailStreak: 0,
415
- ...(tokensUsed !== undefined ? { tokensUsed } : {}),
416
- ...(tokensBaseline !== undefined ? { tokensBaseline } : {}),
417
- ...(tokensCommitted !== undefined ? { tokensCommitted } : {}),
418
- ...(lastAccountedMessageID ? { lastAccountedMessageID } : {}),
419
- }));
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
+ });
420
991
  if (!written) return;
421
992
  console.log(`[session-goal] ${sessionId} settled as ${status}${statusReason ? ` (${statusReason})` : ''}`);
422
993
  if (typeof emitGoalNotification === 'function') {
@@ -436,13 +1007,15 @@ export const createSessionGoalRuntime = ({
436
1007
  }
437
1008
  };
438
1009
 
439
- const runAudit = async ({ goal, assistantText, directory, lastAssistantInfo }) => {
1010
+ const runAudit = async ({ sessionId, goal, assistantText, directory, lastAssistantInfo, signal }) => {
1011
+ if (signal?.aborted) return null;
440
1012
  let service;
441
1013
  try {
442
1014
  service = await getSmallModelService();
443
1015
  } catch {
444
1016
  return null;
445
1017
  }
1018
+ if (signal?.aborted) return null;
446
1019
  const preferredProviderID = typeof lastAssistantInfo?.providerID === 'string' ? lastAssistantInfo.providerID : undefined;
447
1020
  const preferredModelID = typeof lastAssistantInfo?.modelID === 'string' ? lastAssistantInfo.modelID : undefined;
448
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()}"`;
@@ -458,65 +1031,499 @@ export const createSessionGoalRuntime = ({
458
1031
  }
459
1032
  return { verdict, note };
460
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
+ };
461
1060
  try {
462
1061
  // Prefer staying on the session provider. If that provider has no
463
1062
  // suitable small model (404), fall back to any authenticated small
464
1063
  // model so simple goals can still settle instead of stranding as
465
1064
  // "evaluating" then "blocked".
466
1065
  try {
467
- const generated = await service.generateSmallModelText({
1066
+ const generated = await withAbort(() => service.generateSmallModelText({
468
1067
  restrictToPreferredProvider: true,
469
1068
  prompt,
470
1069
  system,
471
1070
  directory,
472
1071
  preferredProviderID,
473
1072
  preferredModelID,
474
- });
1073
+ }));
1074
+ if (signal?.aborted) return null;
475
1075
  return parseAudit(generated);
476
1076
  } catch (restrictedError) {
1077
+ if (restrictedError?.name === 'AbortError' || signal?.aborted) return null;
477
1078
  if (Number(restrictedError?.statusCode) !== 404) throw restrictedError;
478
- const generated = await service.generateSmallModelText({
1079
+ const generated = await withAbort(() => service.generateSmallModelText({
479
1080
  restrictToPreferredProvider: false,
480
1081
  prompt,
481
1082
  system,
482
1083
  directory,
483
1084
  preferredProviderID,
484
1085
  preferredModelID,
485
- });
1086
+ }));
1087
+ if (signal?.aborted) return null;
486
1088
  return parseAudit(generated);
487
1089
  }
488
1090
  } catch (error) {
1091
+ if (error?.name === 'AbortError' || signal?.aborted) return null;
489
1092
  // No authenticated small model (404) or a transient failure — the loop
490
1093
  // still terminates via budget and the turn cap.
491
1094
  if (Number(error?.statusCode) !== 404) {
492
1095
  console.warn('[session-goal] audit failed:', error?.message || error);
493
1096
  }
494
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;
495
1141
  }
496
1142
  };
497
1143
 
498
- const sendContinuation = async ({ sessionId, directory, goal, lastAssistantInfo }) => {
499
- const providerID = typeof lastAssistantInfo?.providerID === 'string' ? lastAssistantInfo.providerID : '';
500
- 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 || '';
501
1171
  if (!providerID || !modelID) {
502
1172
  throw new Error('cannot continue goal: last assistant message has no provider/model');
503
1173
  }
504
- const agent = typeof lastAssistantInfo?.agent === 'string' && lastAssistantInfo.agent
505
- ? lastAssistantInfo.agent
506
- : (typeof lastAssistantInfo?.mode === 'string' ? lastAssistantInfo.mode : '');
507
- const variant = typeof lastAssistantInfo?.variant === 'string' ? lastAssistantInfo.variant : '';
508
- await openCodeFetch(`/session/${encodeURIComponent(sessionId)}/prompt_async`, {
509
- directory,
510
- method: 'POST',
511
- body: {
512
- model: { providerID, modelID },
513
- ...(agent ? { agent } : {}),
514
- ...(variant ? { variant } : {}),
515
- // synthetic: hide the auto-continuation from the user transcript (same
516
- // convention as goal-intro / scheduled-task system parts).
517
- 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
+ },
518
1196
  },
519
- });
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';
520
1527
  };
521
1528
 
522
1529
  const tick = async (sessionId, directory) => {
@@ -528,6 +1535,10 @@ export const createSessionGoalRuntime = ({
528
1535
  return;
529
1536
  }
530
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
+
531
1542
  const session = await openCodeFetch(`/session/${encodeURIComponent(sessionId)}`, { directory })
532
1543
  .catch((error) => {
533
1544
  console.warn(`[session-goal] session fetch failed: ${error?.message || error}`);
@@ -537,8 +1548,131 @@ export const createSessionGoalRuntime = ({
537
1548
  // Sub-agent/task sessions never carry user goals — skip them.
538
1549
  if (typeof session.parentID === 'string' && session.parentID) return;
539
1550
 
540
- const goal = parseGoalMetadata(session);
1551
+ let goal = isMetadataStoreWired()
1552
+ ? await readGoal(sessionId, directory)
1553
+ : parseGoalMetadata(session);
541
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
+ });
542
1676
 
543
1677
  // File-backed objectives: the metadata carries only a flag; the objective
544
1678
  // TEXT lives under the OpenChamber data dir keyed by session id and is
@@ -563,7 +1697,7 @@ export const createSessionGoalRuntime = ({
563
1697
 
564
1698
  let lastAssistant = null;
565
1699
  for (let i = messages.length - 1; i >= 0; i -= 1) {
566
- if (messages[i]?.info?.role === 'assistant') {
1700
+ if (messageRole(messages[i]?.info) === 'assistant') {
567
1701
  lastAssistant = messages[i];
568
1702
  break;
569
1703
  }
@@ -578,7 +1712,7 @@ export const createSessionGoalRuntime = ({
578
1712
  let executionInfo = null;
579
1713
  for (let i = messages.length - 1; i >= 0; i -= 1) {
580
1714
  const info = messages[i]?.info;
581
- if (info?.role === 'assistant' && info.summary !== true) {
1715
+ if (messageRole(info) === 'assistant' && !isSummaryMessage(info)) {
582
1716
  executionInfo = info;
583
1717
  break;
584
1718
  }
@@ -596,7 +1730,7 @@ export const createSessionGoalRuntime = ({
596
1730
  // goal on "evaluating". Corroborate against the live session status before
597
1731
  // bailing — if the session is really idle, treat the orphan as a dead tail
598
1732
  // and let the loop resume; only a genuinely busy session bails here.
599
- if (lastMessageInfo?.role === 'user') return;
1733
+ if (messageRole(lastMessageInfo) === 'user') return;
600
1734
  if (lastAssistantInfo && !(lastAssistantInfo.time?.completed > 0) && !lastAssistantInfo.error) {
601
1735
  const live = await fetchSessionLiveStatus(sessionId, directory);
602
1736
  // live === 'idle' → restart orphan: resume past it. live === null →
@@ -634,14 +1768,21 @@ export const createSessionGoalRuntime = ({
634
1768
  // aborted reply is not evidence of anything).
635
1769
  const abortedTail = lastAssistantInfo.error?.name === 'MessageAbortedError';
636
1770
  if (abortedTail && goal.statusReason !== 'resumed') {
637
- await writeGoal(sessionId, directory, goal.id, () => ({
638
- status: 'paused',
639
- statusReason: 'paused after abort',
640
- tokensUsed,
641
- tokensBaseline,
642
- tokensCommitted,
643
- lastAccountedMessageID,
644
- }));
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
+ });
645
1786
  console.log(`[session-goal] ${sessionId} paused after user abort`);
646
1787
  return;
647
1788
  }
@@ -652,7 +1793,7 @@ export const createSessionGoalRuntime = ({
652
1793
  ? lastAssistantInfo.error.name
653
1794
  : 'assistant turn failed';
654
1795
  await settleGoal({
655
- 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,
656
1797
  });
657
1798
  return;
658
1799
  }
@@ -660,7 +1801,7 @@ export const createSessionGoalRuntime = ({
660
1801
  // Token budget crossed → budgetLimited.
661
1802
  if (typeof goal.tokenBudget === 'number' && tokensUsed >= goal.tokenBudget) {
662
1803
  await settleGoal({
663
- 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,
664
1805
  });
665
1806
  return;
666
1807
  }
@@ -668,7 +1809,7 @@ export const createSessionGoalRuntime = ({
668
1809
  // Auto-continuation safety cap → blocked.
669
1810
  if (goal.turnsUsed >= maxAutoTurns) {
670
1811
  await settleGoal({
671
- 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,
672
1813
  });
673
1814
  return;
674
1815
  }
@@ -683,16 +1824,42 @@ export const createSessionGoalRuntime = ({
683
1824
  let audit = null;
684
1825
  let blockedStreak = 0;
685
1826
  let auditFailStreak = goal.auditFailStreak;
686
- if (lastAssistantInfo.summary === true || abortedTail) {
1827
+ if (isSummaryMessage(lastAssistantInfo) || abortedTail) {
687
1828
  blockedStreak = goal.blockedStreak;
688
1829
  } else {
689
- 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
+ }
690
1853
 
691
1854
  // Audit unavailable: keep going under the hard turn/budget caps rather
692
1855
  // than flipping to "blocked" after a couple of 404s — that left simple
693
1856
  // completed goals stranded as evaluating→blocked when no small model
694
1857
  // was available on the session provider. Resume still re-audits.
695
1858
  if (!audit) {
1859
+ if (auditController.signal.aborted) {
1860
+ console.log(`[session-goal] ${sessionId} audit cancelled`);
1861
+ return;
1862
+ }
696
1863
  auditFailStreak += 1;
697
1864
  console.warn(`[session-goal] ${sessionId} audit unavailable, continuing under hard caps (${auditFailStreak} consecutive)`);
698
1865
  } else {
@@ -701,7 +1868,7 @@ export const createSessionGoalRuntime = ({
701
1868
 
702
1869
  if (audit?.verdict === 'complete') {
703
1870
  await settleGoal({
704
- 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,
705
1872
  });
706
1873
  return;
707
1874
  }
@@ -710,28 +1877,144 @@ export const createSessionGoalRuntime = ({
710
1877
  blockedStreak = goal.blockedStreak + 1;
711
1878
  if (blockedStreak >= BLOCKED_STREAK_LIMIT) {
712
1879
  await settleGoal({
713
- 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,
714
1881
  });
715
1882
  return;
716
1883
  }
717
1884
  }
718
1885
  }
719
1886
 
720
- // --- Continue: persist accounting first, then re-prompt ---
721
- // Order matters: if the write lands and the prompt fails, the goal just
722
- // waits for the next idle tick; the reverse could double-charge a turn.
723
- 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) => ({
724
2005
  tokensUsed,
725
2006
  tokensBaseline,
726
2007
  tokensCommitted,
727
2008
  lastAccountedMessageID,
728
- turnsUsed: current.turnsUsed + 1,
729
2009
  blockedStreak,
730
2010
  auditFailStreak,
731
2011
  statusReason: '',
732
2012
  ...(audit?.note ? { note: audit.note } : {}),
2013
+ pendingContinuation: pending,
2014
+ // Explicit: do not bump turnsUsed until accept/reconcile.
2015
+ turnsUsed: current.turnsUsed,
733
2016
  }));
734
- if (!written) {
2017
+ if (!reserved) {
735
2018
  console.log('[session-goal] goal changed during tick, dropping continuation');
736
2019
  return;
737
2020
  }
@@ -741,18 +2024,132 @@ export const createSessionGoalRuntime = ({
741
2024
  const latest = await fetchRecentMessages(sessionId, directory);
742
2025
  const latestLastInfo = latest && latest.length > 0 ? latest[latest.length - 1]?.info : null;
743
2026
  if (!latestLastInfo || latestLastInfo.id !== lastMessageInfo?.id) {
744
- 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
+ });
745
2034
  return;
746
2035
  }
747
2036
 
748
- // Re-check question blockers immediately before dispatch (uncertain included).
2037
+ // Re-check question blockers immediately before dispatch.
749
2038
  if (typeof isQuestionBlockingGoal === 'function' && isQuestionBlockingGoal(sessionId) === true) {
750
- 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
+ });
2058
+ return;
2059
+ }
2060
+
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
+ }
751
2124
  return;
752
2125
  }
753
2126
 
754
- console.log(`[session-goal] continuing ${sessionId} (turn ${written.turnsUsed}/${maxAutoTurns}, tokens ${written.tokensUsed}${written.tokenBudget ? `/${written.tokenBudget}` : ''})`);
755
- await sendContinuation({ sessionId, directory, goal: { ...written, objective: effectiveObjective }, lastAssistantInfo: executionInfo ?? lastAssistantInfo });
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
+ }
756
2153
  };
757
2154
 
758
2155
  const armTimer = (sessionId, directory, quietMs) => {
@@ -833,23 +2230,70 @@ export const createSessionGoalRuntime = ({
833
2230
  // UI races: metadata may land as paused before the abort event. When the goal
834
2231
  // is already paused for a non-question reason, still notify so question
835
2232
  // auto-delegate timers are cancelled (abort path used to no-op on non-active).
836
- const pauseAfterAbort = async (sessionId, directory) => {
837
- const owner = await resolveGoalOwner(sessionId, directory);
838
- const goal = parseGoalMetadata(owner.session);
839
- if (!goal) return;
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
+
2250
+ const goal = isMetadataStoreWired()
2251
+ ? await readGoal(owner.sessionId, owner.directory)
2252
+ : parseGoalMetadata(owner.session ?? await openCodeFetch(
2253
+ `/session/${encodeURIComponent(owner.sessionId)}`,
2254
+ { directory: owner.directory },
2255
+ ).catch(() => null));
2256
+
2257
+ if (!goal) return null;
840
2258
  if (goal.status === 'paused') {
841
- if (!isQuestionDrivenPause(goal)) {
2259
+ if (notify && !isQuestionDrivenPause(goal)) {
842
2260
  notifyGoalPaused(owner.sessionId, owner.directory);
843
2261
  }
844
- return;
2262
+ return goal;
845
2263
  }
846
- if (goal.status !== 'active') return;
847
- await writeGoal(owner.sessionId, owner.directory, goal.id, () => ({
848
- status: 'paused',
849
- statusReason: 'paused after abort',
850
- }));
851
- console.log(`[session-goal] ${owner.sessionId} paused after user abort`);
852
- 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 });
853
2297
  };
854
2298
 
855
2299
  // Pause the active goal when the agent asks a question — without aborting
@@ -857,24 +2301,54 @@ export const createSessionGoalRuntime = ({
857
2301
  // from a child/sub-agent (any depth) pauses the root owner session's goal.
858
2302
  const pauseForQuestion = async (sessionId, directory) => {
859
2303
  const owner = await resolveGoalOwner(sessionId, directory);
860
- if (!owner.session) return;
861
- clearTimer(owner.sessionId);
862
- const goal = parseGoalMetadata(owner.session);
863
- if (!goal || goal.status !== 'active') return;
864
- await writeGoal(owner.sessionId, owner.directory, goal.id, () => ({
865
- status: 'paused',
866
- statusReason: 'paused for question',
867
- }));
868
- console.log(`[session-goal] ${owner.sessionId} paused for question`);
2304
+ // When the Host store is wired, goal state does not live on the OpenCode
2305
+ // record — owner.session may be null only if the session itself is missing.
2306
+ if (!isMetadataStoreWired() && !owner.session) return;
2307
+ if (isMetadataStoreWired() && !owner.sessionId) return;
2308
+ await pauseGoal(owner.sessionId, owner.directory, 'paused for question', {
2309
+ notify: false,
2310
+ resolveOwner: false,
2311
+ });
869
2312
  // Takeover path already pauses questions; still notify for abort-style symmetry.
870
2313
  };
871
2314
 
872
2315
  const processPayload = (payload, directoryHint = '') => {
873
2316
  if (stopped) return;
874
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
+
875
2348
  const aborted = extractAbortedAssistant(payload);
876
2349
  if (aborted) {
877
2350
  clearTimer(aborted.sessionId);
2351
+ shutdownRecoveryPending.delete(aborted.sessionId);
878
2352
  if (!inflight.has(aborted.sessionId)) {
879
2353
  inflight.add(aborted.sessionId);
880
2354
  pauseAfterAbort(aborted.sessionId, directoryHint)
@@ -912,6 +2386,8 @@ export const createSessionGoalRuntime = ({
912
2386
  const status = extractSessionStatus(payload);
913
2387
  if (status) {
914
2388
  if (status.type === 'idle') {
2389
+ // Authoritative idle after shutdown recovery reopens the continue gate.
2390
+ shutdownRecoveryPending.delete(status.sessionId);
915
2391
  armTimer(status.sessionId, status.directory || directoryHint, idleQuietMs);
916
2392
  } else {
917
2393
  clearTimer(status.sessionId);
@@ -922,11 +2398,12 @@ export const createSessionGoalRuntime = ({
922
2398
  // session.updated carries goal create/resume/pause without a status event.
923
2399
  const update = extractSessionUpdate(payload);
924
2400
  if (update && !update.parentID && update.goal) {
925
- // Explicit UI/user pause (not question-driven): cancel goal timer and
926
- // reverse-pause related question auto-delegate timers. Question pauses
927
- // 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.
928
2405
  if (update.goal.status === 'paused') {
929
- clearTimer(update.sessionId);
2406
+ revokeLocalExecutionPermit(update.sessionId);
930
2407
  if (!isQuestionDrivenPause(update.goal)) {
931
2408
  notifyGoalPaused(update.sessionId, update.directory || directoryHint);
932
2409
  }
@@ -936,6 +2413,8 @@ export const createSessionGoalRuntime = ({
936
2413
  // Kickoff path: a goal set (or resumed — the UI stamps statusReason
937
2414
  // 'resumed') while the session is already idle. Arm a short timer; the
938
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.
939
2418
  if (
940
2419
  update.goal.status === 'active'
941
2420
  && (update.goal.turnsUsed === 0 || update.goal.statusReason === 'resumed')
@@ -950,10 +2429,18 @@ export const createSessionGoalRuntime = ({
950
2429
 
951
2430
  const stop = () => {
952
2431
  stopped = true;
953
- for (const { timer } of timers.values()) {
954
- 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);
955
2440
  }
956
2441
  timers.clear();
2442
+ auditControllers.clear();
2443
+ shutdownRecoveryPending.clear();
957
2444
  };
958
2445
 
959
2446
  return {
@@ -961,5 +2448,22 @@ export const createSessionGoalRuntime = ({
961
2448
  stop,
962
2449
  /** Used by question auto-delegate user takeover / disable paths. */
963
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),
964
2468
  };
965
2469
  };