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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (287) hide show
  1. package/README.md +15 -11
  2. package/bin/cli-opencode.test.js +4 -4
  3. package/bin/cli.js +1 -1
  4. package/bin/lib/cli-startup.js +1 -1
  5. package/bin/lib/cli-startup.test.js +18 -4
  6. package/dist/assets/{AgentAvatar-CLWWNu_Q.js → AgentAvatar-Q1SmFRx2.js} +1 -1
  7. package/dist/assets/AssistantView-CwXkiCAd.js +3 -0
  8. package/dist/assets/{DiagramView-xuSJTnv7.js → DiagramView-BW92schO.js} +1 -1
  9. package/dist/assets/{FadeInOnReveal-Ov0D96d0.js → FadeInOnReveal-BZmwl-zE.js} +1 -1
  10. package/dist/assets/{Icon-DD-BuMfZ.js → Icon-AQPvxMiU.js} +1 -1
  11. package/dist/assets/{MarkdownRenderer-bc_p1H7l.js → MarkdownRenderer-Bv05iDZZ.js} +2 -2
  12. package/dist/assets/{MarkdownRendererImpl-SKi4oG0c.js → MarkdownRendererImpl-C81xqYib.js} +3 -3
  13. package/dist/assets/MarkstreamRendererImpl-CXUCQuKJ.js +3 -0
  14. package/dist/assets/{MobileDetailNavigation-Bnx59U_J.js → MobileDetailNavigation-BMgIu7zc.js} +1 -1
  15. package/dist/assets/{MobileShareBridge-BQlP4J-F.js → MobileShareBridge-BOXGNHmC.js} +2 -2
  16. package/dist/assets/MobileSurface-xuc_z0_G.js +2 -0
  17. package/dist/assets/{MultiRunWindow-ChZGob0v.js → MultiRunWindow-B5At2Z95.js} +1 -1
  18. package/dist/assets/{OnboardingScreen-DtQJxZG-.js → OnboardingScreen-bsPLgz4r.js} +1 -1
  19. package/dist/assets/OverlayScrollbar-4wp4migh.js +2 -0
  20. package/dist/assets/SettingsView-CJ6tcH5_.js +25 -0
  21. package/dist/assets/{SettingsWindow-DfnsFa8y.js → SettingsWindow-CMtX2bMl.js} +1 -1
  22. package/dist/assets/{TerminalView-Dh9MWCpS.js → TerminalView-Dvr-ysTS.js} +1 -1
  23. package/dist/assets/{ToolOutputDialog-BejmjrRk.js → ToolOutputDialog-uH1ZZDSS.js} +2 -2
  24. package/dist/assets/appThemeRegistry-DK7q2juD.js +1 -0
  25. package/dist/assets/{assistant-attachment-cache-C0aRnFkj.js → assistant-attachment-cache-DphJleHr.js} +1 -1
  26. package/dist/assets/{button-D-rATN7w.js → button-CcNJUxUd.js} +1 -1
  27. package/dist/assets/collapsible-ZmZ6rLWs.js +3 -0
  28. package/dist/assets/context-menu-B2LXJgXL.js +1 -0
  29. package/dist/assets/{desktopHosts-B1ixEylr.js → desktopHosts-C6HrP0jq.js} +1 -1
  30. package/dist/assets/{desktopRelayRestore-4JmFUw8r.js → desktopRelayRestore-DdVBp45U.js} +1 -1
  31. package/dist/assets/{diagram-DQf7Y_uR.js → diagram-CxTs9hCQ.js} +1 -1
  32. package/dist/assets/{dialog-bfbvR4VH.js → dialog-KMVL9QKF.js} +1 -1
  33. package/dist/assets/es-CN3E_zXj.js +15 -0
  34. package/dist/assets/{fileMentionTouchSelection-BvrNCroG.js → fileMentionTouchSelection-7RIwlpkB.js} +1 -1
  35. package/dist/assets/{fileQueries-DQc8EJUf.js → fileQueries-CjdBRy8y.js} +1 -1
  36. package/dist/assets/fr-9zZvQZD2.js +13 -0
  37. package/dist/assets/gitApi-yuWg9BN4.js +10 -0
  38. package/dist/assets/{imageSource-BlAJJUEI.js → imageSource-9vQBq3zp.js} +1 -1
  39. package/dist/assets/{input-8r6F_Fza.js → input-CrIvk3HZ.js} +1 -1
  40. package/dist/assets/insertionBoundaries-BiUCSkx-.js +1 -0
  41. package/dist/assets/ja-Dnjk_82N.js +15 -0
  42. package/dist/assets/ko-BxCEE99C.js +15 -0
  43. package/dist/assets/{main-CCE_4y5F.js → main-C9mIBo77.js} +2 -2
  44. package/dist/assets/main-DiL6w6KF.js +169 -0
  45. package/dist/assets/markdown-worker-bS5OgGO3.js +2 -0
  46. package/dist/assets/{markdownHeightCache-1Qo6y6IJ.js → markdownHeightCache-BmXVY_gc.js} +1 -1
  47. package/dist/assets/{markdownTheme-DOfGg-A7.js → markdownTheme-CKac3wKC.js} +1 -1
  48. package/dist/assets/mcpQueries-C7jknHV8.js +1 -0
  49. package/dist/assets/miniChat-CwtUaElU.js +2 -0
  50. package/dist/assets/mobile-DB-6--ws.js +2 -0
  51. package/dist/assets/multirun-TAsxFI8e.js +4 -0
  52. package/dist/assets/{persistence-Ce3AnpEr.js → persistence-56kq8Z4V.js} +2 -2
  53. package/dist/assets/pl-CNNsWnPM.js +15 -0
  54. package/dist/assets/platform-C13fMVEs.js +6 -0
  55. package/dist/assets/pt-BR-BZB2DJMp.js +15 -0
  56. package/dist/assets/{relay-image-stream-DM7atqJs.js → relay-image-stream-DrdVj99c.js} +1 -1
  57. package/dist/assets/renderElectronMiniChatApp-CGj0MNRU.js +2 -0
  58. package/dist/assets/renderMobileApp-CGMMqbsO.js +28 -0
  59. package/dist/assets/runtime-fetch-Fs9ZV2V8.js +1 -0
  60. package/dist/assets/{runtimeConfig-BMvqa5we.js → runtimeConfig-BEANkvOd.js} +1 -1
  61. package/dist/assets/runtimeEndpointReset-A_ZBUJFS.js +147 -0
  62. package/dist/assets/sessionChangeSummary-qOLrP9fw.js +2 -0
  63. package/dist/assets/{sessionLookup-gh-hVRIc.js → sessionLookup-34XFmP9C.js} +1 -1
  64. package/dist/assets/{sortable-tabs-strip-CudoSgV4.js → sortable-tabs-strip-DyEV0Heg.js} +1 -1
  65. package/dist/assets/src-ubCWXZhR.css +2 -0
  66. package/dist/assets/store-DLCiZaA6.js +15 -0
  67. package/dist/assets/{terminalApi-DiF6JmBP.js → terminalApi-CXaBEoa_.js} +1 -1
  68. package/dist/assets/textarea-BBOlRI9O.js +1 -0
  69. package/dist/assets/{tooltip-CQvpK-UV.js → tooltip-CPI4fr1a.js} +1 -1
  70. package/dist/assets/uk-rwQrxeYM.js +15 -0
  71. package/dist/assets/{url-_z7evzdK.js → url-DRDQXjyX.js} +1 -1
  72. package/dist/assets/{useAppFontEffects-Cpmpo7Wb.js → useAppFontEffects-CiDe42I5.js} +57 -43
  73. package/dist/assets/{useAssistantUIStore-CMutVh9B.js → useAssistantUIStore-asNG6K7n.js} +1 -1
  74. package/dist/assets/useDesktopWindowControlsLayout-BlizAlOt.js +1 -0
  75. package/dist/assets/useEffectiveDirectory-C_CouL5v.js +1 -0
  76. package/dist/assets/{useFontPreferences-DcbXQOkc.js → useFontPreferences-B0oUZAiB.js} +1 -1
  77. package/dist/assets/useMobileNavigationStore-DfPClaW-.js +414 -0
  78. package/dist/assets/{useRuntimeAPIs-DSLXJybW.js → useRuntimeAPIs-DwLtO0c_.js} +1 -1
  79. package/dist/assets/useSessionAutoCleanup-DtrfbxAp.js +2 -0
  80. package/dist/assets/{useThemeSystem-DMzWVJaW.js → useThemeSystem-BlNUWH47.js} +1 -1
  81. package/dist/assets/useUIStore-CQYKJnrg.js +1 -0
  82. package/dist/assets/{useWorkerHighlightedLines-Bqn4AiBL.js → useWorkerHighlightedLines-CPNYz6Wf.js} +2 -2
  83. package/dist/assets/{vendor-base-ui-CnHfUmDN.js → vendor-base-ui-CDjyofeD.js} +1 -1
  84. package/dist/assets/{vendor-cmdk-CeAGfrLo.js → vendor-cmdk-BFH0UpXX.js} +1 -1
  85. package/dist/assets/{vendor-dnd-kit-accessibility-BY5LhhUB.js → vendor-dnd-kit-accessibility-BrR3AKvM.js} +1 -1
  86. package/dist/assets/{vendor-dnd-kit-core-CvWxUPdb.js → vendor-dnd-kit-core-CNfdvk6H.js} +1 -1
  87. package/dist/assets/{vendor-dnd-kit-sortable-hHdP9lLb.js → vendor-dnd-kit-sortable-DQUaTeGj.js} +1 -1
  88. package/dist/assets/{vendor-framer-motion-DFy_BSVd.js → vendor-framer-motion-4dogsUk3.js} +1 -1
  89. package/dist/assets/{vendor-legendapp-list-WE32lwNk.js → vendor-legendapp-list-BU7MdPZR.js} +1 -1
  90. package/dist/assets/{vendor-markstream-react-DX55XLGO.js → vendor-markstream-react-qHC2FykU.js} +3 -3
  91. package/dist/assets/{vendor-pierre-diffs-DX7XTPG3.js → vendor-pierre-diffs-CUfqC003.js} +1 -1
  92. package/dist/assets/{vendor-react-DLPElpoQ.js → vendor-react-C94A0AHl.js} +1 -1
  93. package/dist/assets/{vendor-react-drawio-ECtEwvbc.js → vendor-react-drawio-FVlWqKVE.js} +1 -1
  94. package/dist/assets/{vendor-reactuses-core-BnW6GxJt.js → vendor-reactuses-core-C2A3EjUi.js} +1 -1
  95. package/dist/assets/{vendor-sonner-DVzioF3d.js → vendor-sonner-DNqHmUQs.js} +1 -1
  96. package/dist/assets/{vendor-stream-diffs-DWy3xSmp.js → vendor-stream-diffs-CwbKsmlv.js} +2 -2
  97. package/dist/assets/{vendor-tanstack-react-query-D7pys_c9.js → vendor-tanstack-react-query-D0iFK57C.js} +1 -1
  98. package/dist/assets/{vendor-tanstack-react-virtual-tlDgH6PG.js → vendor-tanstack-react-virtual-B2kV6cnT.js} +1 -1
  99. package/dist/assets/{vendor-zustand-B4dlGEM2.js → vendor-zustand-BqLKf6ZY.js} +1 -1
  100. package/dist/assets/zh-CN-BvmBDnkD.js +15 -0
  101. package/dist/assets/zh-TW-9wcHTX0y.js +15 -0
  102. package/dist/index.html +13 -13
  103. package/dist/mini-chat.html +13 -13
  104. package/dist/mobile.html +13 -13
  105. package/package.json +1 -1
  106. package/server/index.js +327 -62
  107. package/server/lib/assistants/DOCUMENTATION.md +14 -9
  108. package/server/lib/assistants/contracts.js +11 -1
  109. package/server/lib/assistants/projection-history.test.js +265 -18
  110. package/server/lib/assistants/routes.js +6 -0
  111. package/server/lib/assistants/service.js +417 -231
  112. package/server/lib/assistants/service.test.js +392 -148
  113. package/server/lib/conversations/DOCUMENTATION.md +1 -1
  114. package/server/lib/conversations/service.js +1 -0
  115. package/server/lib/conversations/service.test.js +16 -0
  116. package/server/lib/conversations/validation.js +11 -1
  117. package/server/lib/event-stream/DOCUMENTATION.md +3 -1
  118. package/server/lib/event-stream/directory-ws-bridge.js +13 -2
  119. package/server/lib/event-stream/global-hub.js +49 -4
  120. package/server/lib/event-stream/reasoning-projection.js +16 -1
  121. package/server/lib/event-stream/reasoning-projection.test.js +15 -0
  122. package/server/lib/event-stream/runtime.js +57 -20
  123. package/server/lib/event-stream/runtime.test.js +112 -0
  124. package/server/lib/llm/DOCUMENTATION.md +33 -9
  125. package/server/lib/llm/catalog.js +1 -1
  126. package/server/lib/llm/completions.js +5 -1
  127. package/server/lib/llm/completions.test.js +3 -3
  128. package/server/lib/llm/generate.js +237 -145
  129. package/server/lib/llm/generate.test.js +201 -46
  130. package/server/lib/llm/routes.js +2 -0
  131. package/server/lib/message-queue/runtime.js +15 -2
  132. package/server/lib/notifications/runtime.js +23 -5
  133. package/server/lib/notifications/runtime.test.js +11 -0
  134. package/server/lib/opencode/DOCUMENTATION.md +85 -17
  135. package/server/lib/opencode/agent-document.d.ts +28 -0
  136. package/server/lib/opencode/agent-document.js +280 -0
  137. package/server/lib/opencode/agent-document.test.js +137 -0
  138. package/server/lib/opencode/agents.js +96 -0
  139. package/server/lib/opencode/auth-state-runtime.js +5 -0
  140. package/server/lib/opencode/bootstrap-runtime.js +2 -0
  141. package/server/lib/opencode/config-entity-routes.js +42 -99
  142. package/server/lib/opencode/config-entity-routes.test.js +107 -0
  143. package/server/lib/opencode/config-hot-reload.integration.test.js +105 -0
  144. package/server/lib/opencode/ensure-cli.js +1 -2
  145. package/server/lib/opencode/env-runtime.js +40 -26
  146. package/server/lib/opencode/env-runtime.test.js +10 -17
  147. package/server/lib/opencode/feature-routes-runtime.js +43 -4
  148. package/server/lib/opencode/feature-routes-runtime.test.js +18 -0
  149. package/server/lib/opencode/http-server-connections.js +98 -0
  150. package/server/lib/opencode/http-server-connections.test.js +321 -0
  151. package/server/lib/opencode/lifecycle.js +349 -39
  152. package/server/lib/opencode/lifecycle.test.js +322 -0
  153. package/server/lib/opencode/managed-capabilities-runtime.js +15 -2
  154. package/server/lib/opencode/managed-capabilities-runtime.test.js +1 -0
  155. package/server/lib/opencode/openchamber-routes.js +7 -1
  156. package/server/lib/opencode/opencode2-pin.js +4 -4
  157. package/server/lib/opencode/opencode2-pin.test.js +1 -0
  158. package/server/lib/opencode/opencode2-sidecar.e2e.test.js +127 -4
  159. package/server/lib/opencode/owned-runtime-upgrade.js +431 -0
  160. package/server/lib/opencode/owned-runtime-upgrade.test.js +160 -0
  161. package/server/lib/opencode/plugin-routes.js +3 -18
  162. package/server/lib/opencode/plugin-routes.test.js +9 -9
  163. package/server/lib/opencode/provider-catalog.js +58 -12
  164. package/server/lib/opencode/provider-catalog.test.js +48 -0
  165. package/server/lib/opencode/proxy.js +252 -46
  166. package/server/lib/opencode/proxy.test.js +235 -2
  167. package/server/lib/opencode/routes.js +296 -62
  168. package/server/lib/opencode/routes.test.js +278 -12
  169. package/server/lib/opencode/runtime-contract.js +401 -0
  170. package/server/lib/opencode/runtime-contract.test.js +214 -0
  171. package/server/lib/opencode/server-opencode-fetch.js +242 -0
  172. package/server/lib/opencode/server-opencode-fetch.test.js +130 -0
  173. package/server/lib/opencode/server-utils-runtime.js +33 -0
  174. package/server/lib/opencode/shared-service.integration.test.js +66 -0
  175. package/server/lib/opencode/shared-service.js +147 -0
  176. package/server/lib/opencode/shared-service.test.js +93 -0
  177. package/server/lib/opencode/shared.js +3 -3
  178. package/server/lib/opencode/shutdown-runtime.js +15 -6
  179. package/server/lib/opencode/shutdown-runtime.test.js +49 -1
  180. package/server/lib/opencode/skill-routes.js +20 -33
  181. package/server/lib/opencode/skill-routes.test.js +35 -2
  182. package/server/lib/opencode/skills.test.js +15 -0
  183. package/server/lib/opencode/startup-pipeline-runtime.js +4 -0
  184. package/server/lib/opencode/static-routes-runtime.js +3 -1
  185. package/server/lib/opencode/static-routes-runtime.test.js +33 -0
  186. package/server/lib/opencode/v2-client.js +14 -3
  187. package/server/lib/opencode/v2-plugin-host-shim/index.mjs +155 -0
  188. package/server/lib/opencode/v2-plugin-host-shim/package.json +4 -0
  189. package/server/lib/permission-auto-accept/DOCUMENTATION.md +5 -5
  190. package/server/lib/permission-auto-accept/runtime.js +14 -7
  191. package/server/lib/permission-auto-accept/runtime.test.js +27 -12
  192. package/server/lib/relay/reasoning-projection.e2e.test.ts +22 -7
  193. package/server/lib/scheduled-tasks/DOCUMENTATION.md +8 -0
  194. package/server/lib/scheduled-tasks/runtime.js +52 -4
  195. package/server/lib/scheduled-tasks/runtime.test.js +80 -1
  196. package/server/lib/session-goal/DOCUMENTATION.md +113 -13
  197. package/server/lib/session-goal/routes.js +9 -0
  198. package/server/lib/session-goal/runtime.js +1579 -117
  199. package/server/lib/session-goal/runtime.test.js +1453 -84
  200. package/server/lib/session-index/DOCUMENTATION.md +18 -15
  201. package/server/lib/session-index/event-ingest.js +89 -16
  202. package/server/lib/session-index/event-ingest.test.js +73 -0
  203. package/server/lib/session-index/host-authority-snapshot.js +92 -0
  204. package/server/lib/session-index/routes.host-archive.test.js +201 -0
  205. package/server/lib/session-index/routes.js +63 -10
  206. package/server/lib/session-index/routes.test.js +8 -8
  207. package/server/lib/session-index/service.js +7 -1
  208. package/server/lib/session-index/sync-runtime.js +161 -34
  209. package/server/lib/session-index/sync-runtime.test.js +155 -1
  210. package/server/lib/session-metadata/DOCUMENTATION.md +61 -16
  211. package/server/lib/session-metadata/routes.js +108 -9
  212. package/server/lib/session-metadata/routes.test.js +47 -2
  213. package/server/lib/session-metadata/session-archive.d.ts +73 -0
  214. package/server/lib/session-metadata/session-archive.js +654 -0
  215. package/server/lib/session-metadata/session-archive.test.js +359 -0
  216. package/server/lib/session-metadata/session-metadata-store.d.ts +36 -0
  217. package/server/lib/session-metadata/session-metadata-store.js +212 -72
  218. package/server/lib/session-metadata/session-metadata-store.test.js +205 -10
  219. package/server/lib/session-metadata/session-projection.d.ts +41 -0
  220. package/server/lib/session-metadata/session-projection.js +255 -0
  221. package/server/lib/session-metadata/session-projection.test.js +64 -0
  222. package/server/lib/session-metadata/system-session.js +55 -0
  223. package/server/lib/session-metadata/system-session.test.js +55 -0
  224. package/server/lib/session-title/DOCUMENTATION.md +40 -7
  225. package/server/lib/session-title/native-runtime.test.js +167 -0
  226. package/server/lib/session-title/runtime.js +67 -42
  227. package/server/lib/session-title/session-access.js +46 -0
  228. package/server/lib/session-title/session-access.test.js +91 -0
  229. package/server/lib/session-turn-pages/DOCUMENTATION.md +9 -8
  230. package/server/lib/session-turn-pages/routes.js +17 -102
  231. package/server/lib/session-turn-pages/routes.test.js +281 -0
  232. package/server/lib/session-turn-pages/session-message-projection.d.ts +13 -0
  233. package/server/lib/session-turn-pages/session-message-projection.js +281 -0
  234. package/server/lib/small-model/DOCUMENTATION.md +101 -147
  235. package/server/lib/small-model/catalog.js +31 -51
  236. package/server/lib/small-model/catalog.test.js +65 -10
  237. package/server/lib/small-model/custom-api.js +68 -0
  238. package/server/lib/small-model/custom-api.test.js +64 -0
  239. package/server/lib/small-model/index.js +124 -240
  240. package/server/lib/small-model/resolve.js +35 -120
  241. package/server/lib/small-model/resolve.test.js +20 -110
  242. package/server/lib/small-model/routes.js +1 -3
  243. package/server/lib/small-model/summary-settings.test.js +157 -253
  244. package/server/opencode-proxy.test.js +2 -0
  245. package/dist/assets/AssistantView-BTCkm8yk.js +0 -3
  246. package/dist/assets/MarkstreamRendererImpl-CnkodGJW.js +0 -3
  247. package/dist/assets/MobileSurface-BXLeiZ4d.js +0 -2
  248. package/dist/assets/OverlayScrollbar-pMwxaCca.js +0 -2
  249. package/dist/assets/SettingsView-B5I0KDTZ.js +0 -25
  250. package/dist/assets/appThemeRegistry-COGfxTFV.js +0 -1
  251. package/dist/assets/collapsible-BXRd5_Hr.js +0 -1
  252. package/dist/assets/context-menu-jGtH6fR9.js +0 -1
  253. package/dist/assets/es-c52LTHHe.js +0 -15
  254. package/dist/assets/fr-PisFbUE8.js +0 -13
  255. package/dist/assets/gitApi-ButwzQ0N.js +0 -9
  256. package/dist/assets/insertionBoundaries-DGs7S5zv.js +0 -1
  257. package/dist/assets/ja-C8Ikun3Z.js +0 -15
  258. package/dist/assets/ko-ClFys5o2.js +0 -15
  259. package/dist/assets/main-Dn9rpsgl.js +0 -169
  260. package/dist/assets/markdown-worker-CptoICmq.js +0 -2
  261. package/dist/assets/miniChat-BpjxVqgg.js +0 -2
  262. package/dist/assets/mobile-nMJCaNq1.js +0 -2
  263. package/dist/assets/multirun-BvSG94mW.js +0 -4
  264. package/dist/assets/pl-CnaKWN5F.js +0 -15
  265. package/dist/assets/platform-CWTIp02q.js +0 -4
  266. package/dist/assets/projectMeta-DOe49dM_.js +0 -3
  267. package/dist/assets/pt-BR-s8OpfRI6.js +0 -15
  268. package/dist/assets/renderElectronMiniChatApp-R6dKh79f.js +0 -2
  269. package/dist/assets/renderMobileApp-y-S8y8v_.js +0 -28
  270. package/dist/assets/runtime-fetch-zIalUnbb.js +0 -1
  271. package/dist/assets/runtimeEndpointReset-B6BxhvR0.js +0 -147
  272. package/dist/assets/sessionChangeSummary-VVBlEXuh.js +0 -2
  273. package/dist/assets/src-BzX5UI-c.css +0 -2
  274. package/dist/assets/store-DJczmczv.js +0 -15
  275. package/dist/assets/textarea-3XR8sUfy.js +0 -1
  276. package/dist/assets/uk-CaX0iqkB.js +0 -15
  277. package/dist/assets/useDesktopWindowControlsLayout-B1GX59-L.js +0 -1
  278. package/dist/assets/useEffectiveDirectory-CN6JoCFw.js +0 -1
  279. package/dist/assets/useMobileNavigationStore-BVjFtXSy.js +0 -414
  280. package/dist/assets/useSessionAutoCleanup-BLjymaFt.js +0 -2
  281. package/dist/assets/useUIStore-Bxv3f23A.js +0 -1
  282. package/dist/assets/zh-CN-20MYEjha.js +0 -15
  283. package/dist/assets/zh-TW-DiN04qTw.js +0 -15
  284. package/server/lib/small-model/call.js +0 -427
  285. package/server/lib/small-model/call.test.js +0 -382
  286. package/server/lib/small-model/opencode-session.js +0 -183
  287. package/server/lib/small-model/opencode-session.test.js +0 -147
@@ -4,7 +4,6 @@ import { createRequire } from 'node:module';
4
4
  import path from 'node:path';
5
5
  import { makeOpenCodeV2Client } from '../opencode/v2-client.js';
6
6
  import { validAssistantDeliveryParts } from '../assistant-delivery-parts.js';
7
- import { reduceBackfillState } from './history-state.js';
8
7
  import { createContactMemoryReader } from './memory.js';
9
8
  import { getWorktrees as defaultListWorktrees } from '../git/service.js';
10
9
  import { contactCardIdentity, parseContactCard, parseContactPart } from './cards.js';
@@ -78,6 +77,7 @@ import {
78
77
  } from './assign.js';
79
78
  import { runContactTurn as defaultRunContactTurn } from './harness.js';
80
79
  import { loadConnectedCatalog } from '../llm/catalog.js';
80
+ import { buildAssistantSessionMetadata } from '../session-metadata/system-session.js';
81
81
 
82
82
  const require = createRequire(import.meta.url);
83
83
  const SCHEMA_VERSION = 13;
@@ -236,7 +236,7 @@ const awaitWithDeadline = async (work, ms = CONTACT_CATALOG_DEADLINE_MS, code =
236
236
  }
237
237
  };
238
238
 
239
- export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, getOpenCodeAuthHeaders, getServerId = async () => null, getAllowedRoots = () => [], listProjects = async () => [], readModelPreferences = async () => null, listScheduledTasks = null, sessionIndexService = null, upsertScheduledTask = null, syncScheduledTaskProject = null, globalEventHub = null, onRevisionTip = null, onContactTurnEvent = null, onContactTurnComplete = null, clock = () => Date.now(), setIntervalFn = setInterval, clearIntervalFn = clearInterval, setImmediateFn = setImmediate, reconcileIntervalMs = 60_000, clientFactory, fetchImpl, createChatCompletion = null, runContactTurn = defaultRunContactTurn, listWorktrees = defaultListWorktrees } = {}) => {
239
+ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, getOpenCodeAuthHeaders, getServerId = async () => null, getAllowedRoots = () => [], listProjects = async () => [], readModelPreferences = async () => null, listScheduledTasks = null, sessionIndexService = null, upsertScheduledTask = null, syncScheduledTaskProject = null, globalEventHub = null, onRevisionTip = null, onContactTurnEvent = null, onContactTurnComplete = null, /** Host archive op: ({ sessionID, directory, archivedAt }) => Promise */ archiveSessionHost = null, /** Idempotent Host metadata drop after successful SDK delete */ forgetSessionHost = null, /** Host store write: (sessionId, patch) => Promise */ persistSessionMetadata = null, /** After Host isolation metadata is committed. */ onSystemSessionPersisted = null, clock = () => Date.now(), setIntervalFn = setInterval, clearIntervalFn = clearInterval, setImmediateFn = setImmediate, reconcileIntervalMs = 60_000, clientFactory, fetchImpl, createChatCompletion = null, runContactTurn = defaultRunContactTurn, listWorktrees = defaultListWorktrees } = {}) => {
240
240
  if (!dbPath || !dataDir) return null;
241
241
  const Database = require('better-sqlite3');
242
242
  fs.mkdirSync(path.dirname(dbPath), { recursive: true });
@@ -320,15 +320,11 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
320
320
  const existing = db.prepare('SELECT directory FROM assistant_session_history WHERE assistant_id=? AND session_id=?').get(assistantID, sessionID);
321
321
  if (existing) {
322
322
  if (existing.directory == null && directory != null) db.prepare('UPDATE assistant_session_history SET directory=? WHERE assistant_id=? AND session_id=?').run(directory, assistantID, sessionID);
323
- db.prepare('DELETE FROM assistant_message_backfill WHERE assistant_id=? AND session_id=?').run(assistantID, sessionID);
324
- db.prepare("UPDATE assistant_message_mirror SET covered=0 WHERE assistant_id=? AND session_id=? AND COALESCE(json_extract(info_json,'$.role'),'')<>'user' AND COALESCE(json_extract(info_json,'$.openchamberAssistantAdmission'),0)<>1").run(assistantID, sessionID);
325
323
  return;
326
324
  }
327
325
  const effectiveDirectory = directory ?? effectiveWorkspace(editable(assistantID));
328
326
  const ordinal = Number(db.prepare('SELECT COALESCE(MAX(ordinal), 0) + 1 AS next FROM assistant_session_history WHERE assistant_id=?').get(assistantID).next);
329
327
  db.prepare('INSERT INTO assistant_session_history(assistant_id, session_id, ordinal, directory, created_at) VALUES (?,?,?,?,?)').run(assistantID, sessionID, ordinal, effectiveDirectory, now());
330
- db.prepare('DELETE FROM assistant_message_backfill WHERE assistant_id=? AND session_id=?').run(assistantID, sessionID);
331
- db.prepare("UPDATE assistant_message_mirror SET covered=0 WHERE assistant_id=? AND session_id=? AND COALESCE(json_extract(info_json,'$.role'),'')<>'user' AND COALESCE(json_extract(info_json,'$.openchamberAssistantAdmission'),0)<>1").run(assistantID, sessionID);
332
328
  };
333
329
  const activeContactTurnsFor = (assistantID) => activeContactTurnsByAssistant.get(assistantID) ?? new Map();
334
330
  const rememberActiveContactTurn = (assistantID, input) => {
@@ -430,20 +426,34 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
430
426
  baseUrl: buildOpenCodeUrl('/', '').replace(/\/$/, ''),
431
427
  authHeaders: getOpenCodeAuthHeaders(),
432
428
  });
433
- const metadata = (row) => ({ openchamber: { assistant: { assistantID: row.assistant_id, name: row.name } } });
429
+ const metadata = (row) => buildAssistantSessionMetadata({
430
+ assistantID: row.assistant_id,
431
+ name: row.name,
432
+ });
433
+ const persistAssistantIsolation = async (sessionID, directory, isolation) => {
434
+ if (typeof persistSessionMetadata !== 'function') return;
435
+ try {
436
+ await persistSessionMetadata(sessionID, isolation);
437
+ onSystemSessionPersisted?.({ sessionID, directory, metadata: isolation });
438
+ } catch {
439
+ // Archive still isolates; Host persist is the sidebar authority when it succeeds.
440
+ }
441
+ };
434
442
  const createSession = async (row) => {
435
443
  const directory = effectiveWorkspace(row);
444
+ const isolation = metadata(row);
436
445
  const result = await invokeSession(() => client().session.create({
437
446
  title: `[Assistant] ${row.name}`,
438
447
  location: { directory },
448
+ metadata: isolation,
439
449
  ...(row.agent ? { agent: row.agent } : {}),
440
450
  ...(row.provider_id && row.model_id ? { model: { id: row.model_id, providerID: row.provider_id, ...(row.variant ? { variant: row.variant } : {}) } } : {}),
441
451
  }));
442
452
  const sessionID = sessionIDOf(result);
443
453
  if (result?.error || !sessionID) fail('upstream_error');
454
+ // Host metadata isolates the session if archive is delayed or unavailable.
455
+ await persistAssistantIsolation(sessionID, directory, isolation);
444
456
  // Archive before binding so ordinary session lists never flash system sessions.
445
- // Metadata still isolates the session if archive fails after create.
446
- // v2 has no session.update archive-metadata write; local history + title prefix are the equivalent isolation.
447
457
  return { sessionID, directory };
448
458
  };
449
459
  const sessionExists = async (row) => { if (!row.current_session_id) return false; const result = await invokeSession(() => client().session.get({ sessionID: row.current_session_id })); if (isMissing(result)) return false; if (result.error) fail('upstream_error'); return Boolean(sessionIDOf(result) || result); };
@@ -488,73 +498,10 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
488
498
  db.prepare("INSERT OR REPLACE INTO assistant_meta(key,value) VALUES ('schema_version',?)").run(String(SCHEMA_VERSION));
489
499
  };
490
500
  migrate();
491
- const mirrorMessage = (assistantID, sessionID, info, ordinal, covered = false) => {
492
- const messageID = info?.id;
493
- if (!nonEmptyString(assistantID) || !nonEmptyString(sessionID) || !nonEmptyString(messageID) || !plainObject(info)) return;
494
- const existing = db.prepare('SELECT ordinal FROM assistant_message_mirror WHERE assistant_id=? AND session_id=? AND message_id=?').get(assistantID, sessionID, messageID);
495
- const nextOrdinal = Number.isSafeInteger(ordinal) ? ordinal : existing?.ordinal ?? Number(db.prepare('SELECT COALESCE(MAX(ordinal), 0) + 1 AS next FROM assistant_message_mirror WHERE assistant_id=? AND session_id=?').get(assistantID, sessionID).next);
496
- db.prepare('INSERT INTO assistant_message_mirror(assistant_id,session_id,message_id,info_json,ordinal,covered,updated_at) VALUES (?,?,?,?,?,?,?) ON CONFLICT(assistant_id,session_id,message_id) DO UPDATE SET info_json=excluded.info_json,ordinal=excluded.ordinal,covered=CASE WHEN assistant_message_mirror.covered=1 OR excluded.covered=1 THEN 1 ELSE 0 END,updated_at=excluded.updated_at').run(assistantID, sessionID, messageID, json(info), nextOrdinal, covered ? 1 : 0, now());
497
- };
498
- const mirrorPart = (assistantID, sessionID, part, ordinal) => {
499
- const messageID = part?.messageID; const partID = part?.id;
500
- if (!nonEmptyString(assistantID) || !nonEmptyString(sessionID) || !nonEmptyString(messageID) || !nonEmptyString(partID) || !plainObject(part)) return;
501
- const existing = db.prepare('SELECT ordinal FROM assistant_message_part_mirror WHERE assistant_id=? AND session_id=? AND message_id=? AND part_id=?').get(assistantID, sessionID, messageID, partID);
502
- const nextOrdinal = Number.isSafeInteger(ordinal) ? ordinal : existing?.ordinal ?? Number(db.prepare('SELECT COALESCE(MAX(ordinal), 0) + 1 AS next FROM assistant_message_part_mirror WHERE assistant_id=? AND session_id=? AND message_id=?').get(assistantID, sessionID, messageID).next);
503
- db.prepare('INSERT INTO assistant_message_part_mirror(assistant_id,session_id,message_id,part_id,part_json,ordinal,updated_at) VALUES (?,?,?,?,?,?,?) ON CONFLICT(assistant_id,session_id,message_id,part_id) DO UPDATE SET part_json=excluded.part_json,ordinal=excluded.ordinal,updated_at=excluded.updated_at').run(assistantID, sessionID, messageID, partID, json(part), nextOrdinal, now());
504
- };
505
- const mirrorAdmittedUserMessage = (row, sessionID, messageID, parts, config) => {
506
- const existingMessage = db.prepare('SELECT info_json FROM assistant_message_mirror WHERE assistant_id=? AND session_id=? AND message_id=?').get(row.assistant_id, sessionID, messageID);
507
- const existingInfo = existingMessage ? parse(existingMessage.info_json) : null;
508
- const info = plainObject(existingInfo) && existingInfo.role === 'user'
509
- ? existingInfo
510
- : {
511
- id: messageID,
512
- sessionID,
513
- role: 'user',
514
- time: { created: now() },
515
- ...(config?.agent ? { agent: config.agent } : {}),
516
- ...(plainObject(config?.model) ? { model: config.model } : {}),
517
- ...(typeof config?.system === 'string' && config.system ? { system: config.system } : {}),
518
- summary: { diffs: [] },
519
- openchamberAssistantAdmission: true,
520
- };
521
- db.exec('BEGIN IMMEDIATE');
522
- try {
523
- mirrorMessage(row.assistant_id, sessionID, info, undefined, true);
524
- const hasAuthoritativeParts = Number(db.prepare("SELECT COUNT(*) AS count FROM assistant_message_part_mirror WHERE assistant_id=? AND session_id=? AND message_id=? AND part_id NOT GLOB 'oc_asst_admission:*'").get(row.assistant_id, sessionID, messageID).count) > 0;
525
- if (!hasAuthoritativeParts) {
526
- db.prepare("DELETE FROM assistant_message_part_mirror WHERE assistant_id=? AND session_id=? AND message_id=? AND part_id GLOB 'oc_asst_admission:*'").run(row.assistant_id, sessionID, messageID);
527
- parts.forEach((part, index) => mirrorPart(row.assistant_id, sessionID, {
528
- ...part,
529
- id: `oc_asst_admission:${index + 1}`,
530
- sessionID,
531
- messageID,
532
- }, index + 1));
533
- }
534
- db.exec('COMMIT');
535
- } catch (error) {
536
- db.exec('ROLLBACK');
537
- throw error;
538
- }
539
- bump();
540
- };
501
+ // V2: OpenCode projection is the sole message-body authority. Legacy mirror /
502
+ // backfill tables may still exist for leftover rows but are never written or
503
+ // read for history serving.
541
504
  const mappedAssistants = (sessionID) => db.prepare("SELECT assistant_id FROM assistant_v2 WHERE current_session_id=? AND tombstone_at IS NULL UNION SELECT h.assistant_id FROM assistant_session_history h JOIN assistant_v2 a ON a.assistant_id=h.assistant_id WHERE h.session_id=? AND a.tombstone_at IS NULL").all(sessionID, sessionID).map((row) => row.assistant_id);
542
- const invalidateCoverage = (assistantID, sessionID) => {
543
- db.prepare('UPDATE assistant_message_mirror SET covered=0 WHERE assistant_id=? AND session_id=?').run(assistantID, sessionID);
544
- db.prepare('INSERT INTO assistant_message_backfill(assistant_id,session_id,cursor,complete,updated_at) VALUES (?,?,?,?,?) ON CONFLICT(assistant_id,session_id) DO UPDATE SET cursor=NULL,complete=0,updated_at=excluded.updated_at').run(assistantID, sessionID, null, 0, now());
545
- };
546
- // Reopen demand backfill without clearing covered/provisional mirrors.
547
- const invalidateBackfill = (assistantID, sessionID) => {
548
- const current = db.prepare('SELECT cursor,complete FROM assistant_message_backfill WHERE assistant_id=? AND session_id=?').get(assistantID, sessionID);
549
- const next = reduceBackfillState({ cursor: current?.cursor ?? null, complete: Boolean(current?.complete) }, 'invalidate');
550
- db.prepare('INSERT INTO assistant_message_backfill(assistant_id,session_id,cursor,complete,updated_at) VALUES (?,?,?,?,?) ON CONFLICT(assistant_id,session_id) DO UPDATE SET cursor=excluded.cursor,complete=excluded.complete,updated_at=excluded.updated_at').run(assistantID, sessionID, next.cursor, next.complete ? 1 : 0, now());
551
- };
552
- // Structural part deletes may leave a covered message incomplete; re-demand that session only.
553
- // Do not blanket-uncover on ordinary message/part upserts — that blanks served history until re-backfill.
554
- const invalidateMessageCoverage = (assistantID, sessionID, messageID) => {
555
- db.prepare('UPDATE assistant_message_mirror SET covered=0 WHERE assistant_id=? AND session_id=? AND message_id=?').run(assistantID, sessionID, messageID);
556
- db.prepare('INSERT INTO assistant_message_backfill(assistant_id,session_id,cursor,complete,updated_at) VALUES (?,?,?,?,?) ON CONFLICT(assistant_id,session_id) DO UPDATE SET cursor=NULL,complete=0,updated_at=excluded.updated_at').run(assistantID, sessionID, null, 0, now());
557
- };
558
505
  // Accept bridge/legacy `{ properties }` and native v2 `{ data }` envelopes.
559
506
  const eventBody = (payload) => {
560
507
  if (!plainObject(payload)) return null;
@@ -608,9 +555,14 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
608
555
  if (type === 'session.error' || type === 'session.execution.failed') {
609
556
  return isUserAbort(body?.error) ? 'cancelled' : 'error';
610
557
  }
611
- // User interrupt: prefer cancelled so contact resume path can notify.
558
+ // Official reason: user | shutdown | superseded | inactivity (ticket 07).
559
+ // Shutdown keeps the upstream execution claim for restart recovery — do not
560
+ // settle the watch or schedule contact resume; wait for authoritative terminal.
612
561
  if (type === 'session.execution.interrupted') {
613
- return isUserAbort(body?.error) ? 'cancelled' : 'error';
562
+ const reason = typeof body?.reason === 'string' ? body.reason.trim() : '';
563
+ if (reason === 'shutdown') return null;
564
+ if (reason === 'user' || isUserAbort(body?.error)) return 'cancelled';
565
+ return 'error';
614
566
  }
615
567
  if (type === 'session.execution.succeeded') return 'complete';
616
568
  if (type === 'session.execution.started' || type === 'session.retry.scheduled') return 'busy';
@@ -909,25 +861,26 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
909
861
  if (!nonEmptyString(sessionID) || !plainObject(info)) return false;
910
862
  const cancelled = info.role === 'assistant' && isUserAbort(info.error)
911
863
  ? reportAssignedSession(sessionID, 'cancelled') : false;
912
- const assistants = mappedAssistants(sessionID); for (const assistantID of assistants) { const current = assistant(assistantID)?.current_session_id === sessionID; mirrorMessage(assistantID, sessionID, info, undefined, current); if (!current) invalidateBackfill(assistantID, sessionID); } return cancelled || assistants.length > 0;
864
+ // Body authority is OpenCode projection GET — do not mirror message bodies.
865
+ return cancelled || mappedAssistants(sessionID).length > 0;
913
866
  }
914
867
  if (payload.type === 'message.part.updated') {
915
868
  if (!plainObject(body)) return false;
916
869
  const part = body.part; const sessionID = body.sessionID ?? part?.sessionID;
917
870
  if (!nonEmptyString(sessionID) || !plainObject(part)) return false;
918
- const assistants = mappedAssistants(sessionID); for (const assistantID of assistants) { db.prepare("DELETE FROM assistant_message_part_mirror WHERE assistant_id=? AND session_id=? AND message_id=? AND part_id GLOB 'oc_asst_admission:*'").run(assistantID, sessionID, part.messageID); mirrorPart(assistantID, sessionID, part); if (assistant(assistantID)?.current_session_id !== sessionID) invalidateBackfill(assistantID, sessionID); } return assistants.length > 0;
871
+ return mappedAssistants(sessionID).length > 0;
919
872
  }
920
873
  if (payload.type === 'message.removed') {
921
874
  if (!plainObject(body)) return false;
922
875
  const sessionID = body.sessionID; const messageID = body.messageID;
923
876
  if (!nonEmptyString(sessionID) || !nonEmptyString(messageID)) return false;
924
- const assistants = mappedAssistants(sessionID); for (const assistantID of assistants) { db.prepare('DELETE FROM assistant_message_part_mirror WHERE assistant_id=? AND session_id=? AND message_id=?').run(assistantID, sessionID, messageID); db.prepare('DELETE FROM assistant_message_mirror WHERE assistant_id=? AND session_id=? AND message_id=?').run(assistantID, sessionID, messageID); } return assistants.length > 0;
877
+ return mappedAssistants(sessionID).length > 0;
925
878
  }
926
879
  if (payload.type === 'message.part.removed') {
927
880
  if (!plainObject(body)) return false;
928
881
  const sessionID = body.sessionID; const messageID = body.messageID ?? body.part?.messageID; const partID = body.partID ?? body.part?.id;
929
882
  if (!nonEmptyString(sessionID) || !nonEmptyString(messageID) || !nonEmptyString(partID)) return false;
930
- const assistants = mappedAssistants(sessionID); for (const assistantID of assistants) { db.prepare('DELETE FROM assistant_message_part_mirror WHERE assistant_id=? AND session_id=? AND message_id=? AND part_id=?').run(assistantID, sessionID, messageID, partID); if (assistant(assistantID)?.current_session_id !== sessionID) invalidateMessageCoverage(assistantID, sessionID, messageID); } return assistants.length > 0;
883
+ return mappedAssistants(sessionID).length > 0;
931
884
  }
932
885
  const sessionID = plainObject(body) ? eventSessionID(body) : '';
933
886
  if (payload.type === 'session.execution.started' || payload.type === 'session.retry.scheduled') {
@@ -939,9 +892,13 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
939
892
  || payload.type === 'session.execution.interrupted') {
940
893
  if (!sessionID) return false;
941
894
  const status = settleStatusFromEvent(payload, body);
942
- if (!status) return false;
895
+ // shutdown interrupt: status null — keep watch in-flight (pending recovery).
896
+ if (!status) {
897
+ return listWatchesBySession(db, sessionID).length > 0
898
+ || mappedAssistants(sessionID).length > 0;
899
+ }
943
900
  const reported = reportAssignedSession(sessionID, status);
944
- const assistants = mappedAssistants(sessionID); for (const assistantID of assistants) { if (assistant(assistantID)?.current_session_id !== sessionID) invalidateBackfill(assistantID, sessionID); }
901
+ const assistants = mappedAssistants(sessionID);
945
902
  return reported || assistants.some((assistantID) => assistant(assistantID)?.current_session_id !== sessionID);
946
903
  }
947
904
  if (payload.type === 'session.idle' || payload.type === 'session.error') {
@@ -949,7 +906,7 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
949
906
  // session.idle is a weak fallback: watermarked watches need execution.* or reconcile.
950
907
  const status = settleStatusFromEvent(payload, body);
951
908
  const reported = reportAssignedSession(sessionID, status, { requireExecutionOwned: payload.type === 'session.idle' });
952
- const assistants = mappedAssistants(sessionID); for (const assistantID of assistants) { if (assistant(assistantID)?.current_session_id !== sessionID) invalidateBackfill(assistantID, sessionID); }
909
+ const assistants = mappedAssistants(sessionID);
953
910
  return reported || assistants.some((assistantID) => assistant(assistantID)?.current_session_id !== sessionID);
954
911
  }
955
912
  if (payload.type === 'session.status') {
@@ -957,7 +914,7 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
957
914
  const status = settleStatusFromEvent(payload, body);
958
915
  if (!status) return false;
959
916
  const reported = reportAssignedSession(sessionID, status, { requireExecutionOwned: status === 'complete' });
960
- const assistants = mappedAssistants(sessionID); for (const assistantID of assistants) { if (assistant(assistantID)?.current_session_id !== sessionID) invalidateBackfill(assistantID, sessionID); }
917
+ const assistants = mappedAssistants(sessionID);
961
918
  return reported || assistants.some((assistantID) => assistant(assistantID)?.current_session_id !== sessionID);
962
919
  }
963
920
  if (payload.type === 'question.asked' || payload.type === 'permission.asked'
@@ -975,162 +932,364 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
975
932
  }
976
933
  return null;
977
934
  };
978
- const clearUncoveredSessionMirror = (assistantID, sessionID) => {
979
- db.prepare('DELETE FROM assistant_message_part_mirror WHERE assistant_id=? AND session_id=? AND message_id IN (SELECT message_id FROM assistant_message_mirror WHERE assistant_id=? AND session_id=? AND covered=0)').run(assistantID, sessionID, assistantID, sessionID);
980
- db.prepare('DELETE FROM assistant_message_mirror WHERE assistant_id=? AND session_id=? AND covered=0').run(assistantID, sessionID);
981
- };
982
- const writeBackfillState = (assistantID, sessionID, next) => {
983
- db.prepare('INSERT INTO assistant_message_backfill(assistant_id,session_id,cursor,complete,updated_at) VALUES (?,?,?,?,?) ON CONFLICT(assistant_id,session_id) DO UPDATE SET cursor=excluded.cursor,complete=excluded.complete,updated_at=excluded.updated_at').run(assistantID, sessionID, next.cursor, next.complete ? 1 : 0, now());
984
- };
985
- // Authoritative 404 means this session ID is gone from OpenCode. Converge its
986
- // backfill so one deleted archive cannot block demand scans of other sessions.
987
- // Covered/admitted rows stay; only uncovered event mirrors are dropped. Safe
988
- // under concurrent ensure: a replaced current binding archives under its old
989
- // ID, while a still-current missing ID is recreated with a new session ID.
990
- const completeMissingSessionBackfill = (assistantID, sessionID) => {
991
- db.exec('BEGIN IMMEDIATE');
935
+ // Host history cursor: binding ordinal + official opaque upstream cursor +
936
+ // page-local skip. Carries positions/IDs only — never message bodies.
937
+ const decodeCursor = (value) => {
938
+ if (value == null || value === '') return null;
992
939
  try {
993
- const current = db.prepare('SELECT cursor,complete FROM assistant_message_backfill WHERE assistant_id=? AND session_id=?').get(assistantID, sessionID);
994
- const next = reduceBackfillState({ cursor: current?.cursor ?? null, complete: Boolean(current?.complete) }, 'session-missing');
995
- if (next.disposition === 'discard-provisional') clearUncoveredSessionMirror(assistantID, sessionID);
996
- writeBackfillState(assistantID, sessionID, next);
997
- db.exec('COMMIT');
998
- return true;
999
- } catch (error) { db.exec('ROLLBACK'); throw error; }
1000
- };
1001
- const fetchBackfillMessages = async (history, directory, cursor) => {
940
+ const parsed = parse(Buffer.from(String(value), 'base64url').toString('utf8'));
941
+ if (!Number.isSafeInteger(parsed?.sessionOrdinal)) fail('validation_error');
942
+ if (parsed.upstream != null && !nonEmptyString(parsed.upstream)) fail('validation_error');
943
+ const skip = parsed.skip == null ? 0 : parsed.skip;
944
+ if (!Number.isSafeInteger(skip) || skip < 0) fail('validation_error');
945
+ return {
946
+ sessionOrdinal: parsed.sessionOrdinal,
947
+ upstream: parsed.upstream ?? null,
948
+ skip,
949
+ };
950
+ } catch (error) {
951
+ if (error instanceof AssistantError) throw error;
952
+ fail('validation_error');
953
+ }
954
+ };
955
+ const encodeCursor = (state) => Buffer.from(json({
956
+ sessionOrdinal: state.sessionOrdinal,
957
+ upstream: state.upstream ?? null,
958
+ skip: state.skip ?? 0,
959
+ })).toString('base64url');
960
+ // Official v2: GET /api/session/:id/message — first page order=desc; continuation
961
+ // cursor only (never combine order+cursor). Prefer fetchImpl when injected.
962
+ const fetchProjectionMessages = async (sessionID, directory, cursor, pageLimit = BACKFILL_PAGE_SIZE) => {
1002
963
  for (let attempt = 0; attempt < BACKFILL_MESSAGES_ATTEMPTS; attempt++) {
1003
964
  try {
1004
- const result = await invokeSession(() => client().message.list({ sessionID: history.session_id, limit: BACKFILL_PAGE_SIZE, order: 'desc', ...(cursor ? { cursor } : {}) }));
1005
- if (!result?.error || isMissing(result)) return result;
1006
- if (!isTransientMessagesFailure(result, null) || attempt === BACKFILL_MESSAGES_ATTEMPTS - 1) fail('upstream_error');
965
+ if (typeof fetchImpl === 'function') {
966
+ const base = String(buildOpenCodeUrl('/', '') || '').replace(/\/$/, '') || 'http://opencode.local';
967
+ const url = new URL(`${base}/api/session/${encodeURIComponent(sessionID)}/message`);
968
+ url.searchParams.set('limit', String(pageLimit));
969
+ // Official contract: do not combine order with cursor.
970
+ if (cursor) url.searchParams.set('cursor', cursor);
971
+ else url.searchParams.set('order', 'desc');
972
+ if (directory) url.searchParams.set('directory', directory);
973
+ const headers = {
974
+ Accept: 'application/json',
975
+ ...(typeof getOpenCodeAuthHeaders === 'function' ? getOpenCodeAuthHeaders() : {}),
976
+ };
977
+ const response = await fetchImpl(url, { method: 'GET', headers });
978
+ const status = response?.status ?? response?.statusCode;
979
+ if (status === 404) return { error: { status: 404 }, status: 404 };
980
+ if (status != null && status >= 400) {
981
+ const errBody = { error: { status }, status };
982
+ if (!isTransientMessagesFailure(errBody, null) || attempt === BACKFILL_MESSAGES_ATTEMPTS - 1) return errBody;
983
+ } else {
984
+ const payload = typeof response?.json === 'function' ? await response.json() : null;
985
+ return {
986
+ data: Array.isArray(payload?.data) ? payload.data : (Array.isArray(payload) ? payload : payload?.data),
987
+ cursor: payload?.cursor,
988
+ response: {
989
+ status: status ?? 200,
990
+ headers: {
991
+ get: (name) => {
992
+ if (typeof response?.headers?.get === 'function') return response.headers.get(name);
993
+ return null;
994
+ },
995
+ },
996
+ },
997
+ };
998
+ }
999
+ } else {
1000
+ const result = await invokeSession(() => client().message.list({
1001
+ sessionID,
1002
+ limit: pageLimit,
1003
+ // First page: order=desc. Continuation: opaque cursor only.
1004
+ ...(cursor ? { cursor } : { order: 'desc' }),
1005
+ }));
1006
+ if (!result?.error || isMissing(result)) return result;
1007
+ if (!isTransientMessagesFailure(result, null) || attempt === BACKFILL_MESSAGES_ATTEMPTS - 1) return result;
1008
+ }
1007
1009
  } catch (error) {
1008
1010
  if (error instanceof AssistantError) throw error;
1009
- if (!isTransientMessagesFailure(null, error) || attempt === BACKFILL_MESSAGES_ATTEMPTS - 1) fail('upstream_error');
1011
+ if (isMissingError(error)) return { error: { status: 404 }, status: 404 };
1012
+ if (!isTransientMessagesFailure(null, error) || attempt === BACKFILL_MESSAGES_ATTEMPTS - 1) {
1013
+ const status = getHttpStatus(error);
1014
+ return { error: { status: status ?? 500 }, status: status ?? 500 };
1015
+ }
1010
1016
  }
1011
1017
  await sleep(BACKFILL_RETRY_MS[Math.min(attempt, BACKFILL_RETRY_MS.length - 1)]);
1012
1018
  }
1013
- fail('upstream_error');
1019
+ return { error: { status: 500 }, status: 500 };
1014
1020
  };
1015
- const backfillSession = async (history) => {
1016
- const state = db.prepare('SELECT cursor,complete FROM assistant_message_backfill WHERE assistant_id=? AND session_id=?').get(history.assistant_id, history.session_id);
1017
- if (state?.complete) return true;
1018
- const cursor = state?.cursor ?? null;
1019
- let directory = history.directory ?? null;
1020
- if (directory == null) {
1021
- const session = await client().session.get({ sessionID: history.session_id }).catch(() => null);
1022
- const resolved = resolveArchivedDirectory(history.assistant_id, session?.data?.directory, session?.data?.project?.worktree);
1021
+ const resolveBindingDirectory = async (binding) => {
1022
+ let directory = binding.directory ?? null;
1023
+ if (directory != null) return directory;
1024
+ const session = await invokeSession(() => client().session.get({ sessionID: binding.session_id })).catch(() => null);
1025
+ if (!session?.error) {
1026
+ const resolved = resolveArchivedDirectory(
1027
+ binding.assistant_id,
1028
+ sessionDirectoryOf(session),
1029
+ session?.data?.directory,
1030
+ session?.data?.project?.worktree,
1031
+ );
1023
1032
  if (resolved) {
1024
- db.prepare('UPDATE assistant_session_history SET directory=? WHERE assistant_id=? AND session_id=? AND directory IS NULL').run(resolved, history.assistant_id, history.session_id);
1033
+ db.prepare('UPDATE assistant_session_history SET directory=? WHERE assistant_id=? AND session_id=? AND directory IS NULL')
1034
+ .run(resolved, binding.assistant_id, binding.session_id);
1025
1035
  directory = resolved;
1026
1036
  }
1027
1037
  }
1028
- const result = await fetchBackfillMessages(history, directory, cursor);
1029
- if (isMissing(result)) return completeMissingSessionBackfill(history.assistant_id, history.session_id);
1030
- if (result?.error) fail('upstream_error');
1031
- const entries = projectionEntries(result, history.session_id);
1032
- if (!entries) fail('upstream_error');
1033
- db.exec('BEGIN IMMEDIATE');
1034
- try {
1035
- entries.forEach((entry) => { const info = entry?.info ?? entry; if (nonEmptyString(info?.sessionID) && info.sessionID !== history.session_id) return; const parts = Array.isArray(entry?.parts) ? entry.parts : []; const messageOrdinal = Number.isSafeInteger(info?.time?.created) ? info.time.created : undefined; mirrorMessage(history.assistant_id, history.session_id, info, messageOrdinal, true); const partIDs = parts.filter((part) => nonEmptyString(part?.id)).map((part) => part.id); if (partIDs.length) db.prepare(`DELETE FROM assistant_message_part_mirror WHERE assistant_id=? AND session_id=? AND message_id=? AND part_id NOT IN (${partIDs.map(() => '?').join(',')})`).run(history.assistant_id, history.session_id, info?.id, ...partIDs); else db.prepare('DELETE FROM assistant_message_part_mirror WHERE assistant_id=? AND session_id=? AND message_id=?').run(history.assistant_id, history.session_id, info?.id); parts.forEach((part, partIndex) => mirrorPart(history.assistant_id, history.session_id, part, partIndex + 1)); });
1036
- const nextCursor = projectionCursor(result) ?? result?.response?.headers?.get?.('x-next-cursor') ?? null;
1037
- const next = reduceBackfillState({ cursor, complete: false }, { type: 'page', nextCursor });
1038
- // Successful pages only upsert authoritative rows; never delete provisional
1039
- // event mirrors that the snapshot omitted — only 404 / message.removed /
1040
- // assistant deletion may shrink the message set.
1041
- writeBackfillState(history.assistant_id, history.session_id, next);
1042
- db.exec('COMMIT');
1043
- return next.complete;
1044
- } catch (error) { db.exec('ROLLBACK'); throw error; }
1045
- };
1046
- const decodeCursor = (value) => { if (value == null || value === '') return null; try { const parsed = parse(Buffer.from(String(value), 'base64url').toString('utf8')); return Number.isSafeInteger(parsed?.sessionOrdinal) && Number.isSafeInteger(parsed?.messageOrdinal) && nonEmptyString(parsed?.messageID) && (parsed.scanSessionOrdinal == null || Number.isSafeInteger(parsed.scanSessionOrdinal)) ? parsed : fail('validation_error'); } catch (error) { if (error instanceof AssistantError) throw error; fail('validation_error'); } };
1047
- const encodeCursor = (row, scanSessionOrdinal = row.session_ordinal) => Buffer.from(json({ sessionOrdinal: row.session_ordinal, messageOrdinal: row.message_ordinal, messageID: row.message_id, scanSessionOrdinal })).toString('base64url');
1048
- const loadProjectionSession = async (history, pageLimit = BACKFILL_PAGE_SIZE) => {
1049
- let directory = history.directory ?? null;
1050
- if (directory == null) {
1051
- const session = await invokeSession(() => client().session.get({ sessionID: history.session_id })).catch(() => null);
1052
- const resolved = resolveArchivedDirectory(history.assistant_id, sessionDirectoryOf(session), session?.data?.directory, session?.data?.project?.worktree);
1053
- if (resolved) {
1054
- db.prepare('UPDATE assistant_session_history SET directory=? WHERE assistant_id=? AND session_id=? AND directory IS NULL').run(resolved, history.assistant_id, history.session_id);
1055
- directory = resolved;
1038
+ return directory;
1039
+ };
1040
+ /**
1041
+ * Walk one binding from an official upstream position (tip or opaque cursor).
1042
+ * Emits rows in authoritative projection order (order=desc ⇒ newest first).
1043
+ * Host page boundary is `skip` within the current upstream page — no tip rescan.
1044
+ */
1045
+ const loadProjectionBinding = async (binding, {
1046
+ upstream = null,
1047
+ skip = 0,
1048
+ need = BACKFILL_PAGE_SIZE,
1049
+ maxPages = BACKFILL_MAX_PAGES,
1050
+ pageLimit = BACKFILL_PAGE_SIZE,
1051
+ } = {}) => {
1052
+ const directory = await resolveBindingDirectory(binding);
1053
+ const collected = [];
1054
+ let cursor = upstream;
1055
+ let pageSkip = Math.max(0, skip);
1056
+ let exhausted = false;
1057
+ let failed = false;
1058
+ let failedStatus = null;
1059
+ let pagesUsed = 0;
1060
+ let resumeUpstream = upstream;
1061
+ let resumeSkip = pageSkip;
1062
+ const pageSize = Math.min(BACKFILL_PAGE_SIZE, Math.max(1, pageLimit));
1063
+
1064
+ for (let page = 0; page < maxPages; page++) {
1065
+ resumeUpstream = cursor;
1066
+ resumeSkip = pageSkip;
1067
+ const result = await fetchProjectionMessages(binding.session_id, directory, cursor, pageSize);
1068
+ pagesUsed += 1;
1069
+ if (isMissing(result)) {
1070
+ exhausted = true;
1071
+ return {
1072
+ entries: collected, directory, missing: true, failed: false, failedStatus: null,
1073
+ exhausted: true, pagesUsed, resumeUpstream: null, resumeSkip: 0,
1074
+ };
1056
1075
  }
1076
+ if (result?.error) {
1077
+ failed = true;
1078
+ failedStatus = messagesErrorStatus(result) ?? result.status ?? 500;
1079
+ break;
1080
+ }
1081
+ const entries = projectionEntries(result, binding.session_id);
1082
+ if (!entries) {
1083
+ failed = true;
1084
+ failedStatus = 500;
1085
+ break;
1086
+ }
1087
+ // Empty page is the authoritative end (non-empty pages may still carry next).
1088
+ if (entries.length === 0) {
1089
+ exhausted = true;
1090
+ resumeUpstream = null;
1091
+ resumeSkip = 0;
1092
+ break;
1093
+ }
1094
+ let index = 0;
1095
+ let stoppedEarly = false;
1096
+ for (; index < entries.length; index++) {
1097
+ if (index < pageSkip) continue;
1098
+ const entry = entries[index];
1099
+ const info = entry?.info ?? entry;
1100
+ if (!plainObject(info) || !nonEmptyString(info.id)) continue;
1101
+ if (nonEmptyString(info.sessionID) && info.sessionID !== binding.session_id) continue;
1102
+ collected.push({
1103
+ session_id: binding.session_id,
1104
+ session_ordinal: binding.ordinal,
1105
+ directory,
1106
+ message_id: info.id,
1107
+ // Source seq within authoritative page order (not time.created).
1108
+ seq: collected.length,
1109
+ info,
1110
+ parts: Array.isArray(entry?.parts) ? entry.parts : [],
1111
+ });
1112
+ if (collected.length >= need) {
1113
+ // Keep resumeSkip on this row when it is the probe past `limit`.
1114
+ stoppedEarly = true;
1115
+ break;
1116
+ }
1117
+ }
1118
+ pageSkip = 0;
1119
+ if (collected.length >= need) {
1120
+ // Page boundary: resume at the probe row (or next upstream page if none left).
1121
+ if (stoppedEarly && index < entries.length) {
1122
+ resumeUpstream = cursor;
1123
+ resumeSkip = index;
1124
+ } else {
1125
+ const next = projectionCursor(result);
1126
+ resumeUpstream = next || null;
1127
+ resumeSkip = 0;
1128
+ // Official: last non-empty page may still expose next; empty follow-up ends.
1129
+ if (!next) exhausted = true;
1130
+ }
1131
+ break;
1132
+ }
1133
+ const next = projectionCursor(result);
1134
+ if (!next) {
1135
+ exhausted = true;
1136
+ resumeUpstream = null;
1137
+ resumeSkip = 0;
1138
+ break;
1139
+ }
1140
+ cursor = next;
1141
+ pageSkip = 0;
1142
+ resumeUpstream = next;
1143
+ resumeSkip = 0;
1057
1144
  }
1058
- const collected = [];
1059
- let cursor = null;
1060
- for (let page = 0; page < BACKFILL_MAX_PAGES; page++) {
1061
- const result = await fetchBackfillMessages(history, directory, cursor);
1062
- if (isMissing(result)) return { entries: collected, directory, missing: true };
1063
- if (result?.error) fail('upstream_error');
1064
- const entries = projectionEntries(result, history.session_id);
1065
- if (!entries) fail('upstream_error');
1066
- collected.push(...entries);
1067
- cursor = projectionCursor(result);
1068
- if (!cursor) break;
1069
- if (collected.length >= pageLimit) break;
1070
- }
1071
- return { entries: collected.slice(0, pageLimit), directory, missing: false };
1145
+
1146
+ return {
1147
+ entries: collected,
1148
+ directory,
1149
+ missing: false,
1150
+ failed,
1151
+ failedStatus,
1152
+ exhausted: exhausted && !failed,
1153
+ pagesUsed,
1154
+ resumeUpstream,
1155
+ resumeSkip,
1156
+ };
1157
+ };
1158
+ const listHistoryBindings = (row) => {
1159
+ const history = db.prepare('SELECT session_id, directory, ordinal FROM assistant_session_history WHERE assistant_id=? ORDER BY ordinal ASC')
1160
+ .all(row.assistant_id);
1161
+ const bindings = history.map((item) => ({
1162
+ assistant_id: row.assistant_id,
1163
+ session_id: item.session_id,
1164
+ directory: item.directory ?? null,
1165
+ ordinal: item.ordinal,
1166
+ }));
1167
+ const currentIsArchived = row.current_session_id != null
1168
+ && Boolean(db.prepare('SELECT 1 FROM assistant_session_history WHERE assistant_id=? AND session_id=?').get(row.assistant_id, row.current_session_id));
1169
+ if (row.current_session_id && !currentIsArchived) {
1170
+ const nextOrdinal = Number(db.prepare('SELECT COALESCE(MAX(ordinal), 0) + 1 AS next FROM assistant_session_history WHERE assistant_id=?').get(row.assistant_id).next);
1171
+ bindings.push({
1172
+ assistant_id: row.assistant_id,
1173
+ session_id: row.current_session_id,
1174
+ directory: effectiveWorkspace(row),
1175
+ ordinal: nextOrdinal,
1176
+ });
1177
+ }
1178
+ return bindings;
1072
1179
  };
1073
1180
  const historicalMessages = async (assistantID, input = {}) => {
1074
- const row = editable(assistantID); const limit = input.limit == null ? 50 : Number(input.limit); if (!Number.isInteger(limit) || limit < 1 || limit > 100) fail('validation_error'); const before = decodeCursor(input.before);
1075
- const currentSessionOrdinal = Number(db.prepare('SELECT COALESCE(MAX(ordinal), 0) + 1 AS next FROM assistant_session_history WHERE assistant_id=?').get(row.assistant_id).next);
1076
- const currentIsArchived = row.current_session_id != null && Boolean(db.prepare('SELECT 1 FROM assistant_session_history WHERE assistant_id=? AND session_id=?').get(row.assistant_id, row.current_session_id));
1077
- const beforeIncludes = (sessionOrdinal, messageOrdinal, messageID) => before == null || sessionOrdinal < before.sessionOrdinal || (sessionOrdinal === before.sessionOrdinal && (messageOrdinal < before.messageOrdinal || (messageOrdinal === before.messageOrdinal && messageID < before.messageID)));
1078
- const pageRows = () => {
1079
- // Archived mirrors include covered=1 authoritative rows and covered=0
1080
- // provisional event mirrors so a partial REST snapshot cannot hide a reply
1081
- // already observed on the live channel.
1082
- const historical = db.prepare(`SELECT h.ordinal AS session_ordinal,h.directory,m.ordinal AS message_ordinal,m.message_id,m.info_json,m.session_id FROM assistant_session_history h JOIN assistant_message_mirror m ON m.assistant_id=h.assistant_id AND m.session_id=h.session_id WHERE h.assistant_id=? AND (? IS NULL OR h.ordinal<? OR (h.ordinal=? AND (m.ordinal<? OR (m.ordinal=? AND m.message_id<?)))) ORDER BY h.ordinal DESC,m.ordinal DESC,m.message_id DESC LIMIT ?`).all(row.assistant_id, before?.sessionOrdinal ?? null, before?.sessionOrdinal ?? 0, before?.sessionOrdinal ?? 0, before?.messageOrdinal ?? 0, before?.messageOrdinal ?? 0, before?.messageID ?? '', limit + 1);
1083
- const current = row.current_session_id && !currentIsArchived
1084
- ? db.prepare('SELECT ? AS session_ordinal,? AS directory,ordinal AS message_ordinal,message_id,info_json,session_id FROM assistant_message_mirror WHERE assistant_id=? AND session_id=? AND covered=1 ORDER BY ordinal DESC,message_id DESC LIMIT ?').all(currentSessionOrdinal, effectiveWorkspace(row), row.assistant_id, row.current_session_id, limit + 1).filter((message) => beforeIncludes(message.session_ordinal, message.message_ordinal, message.message_id))
1085
- : [];
1086
- return [...historical, ...current].sort((left, right) => right.session_ordinal - left.session_ordinal || right.message_ordinal - left.message_ordinal || right.message_id.localeCompare(left.message_id)).slice(0, limit + 1);
1087
- };
1088
- const nextIncomplete = (boundary = before?.scanSessionOrdinal ?? before?.sessionOrdinal ?? null) => db.prepare(`SELECT h.* FROM assistant_session_history h LEFT JOIN assistant_message_backfill b ON b.assistant_id=h.assistant_id AND b.session_id=h.session_id WHERE h.assistant_id=? AND (b.complete IS NULL OR b.complete=0) AND (? IS NULL OR h.ordinal<=?) ORDER BY h.ordinal DESC LIMIT 1`).get(row.assistant_id, boundary, boundary ?? 0);
1089
- let rows = pageRows();
1090
- // Demand-backfill incomplete archived sessions even when provisional
1091
- // mirrors already fill the page, so authoritative upserts can elevate
1092
- // covered=0 fallbacks. Cap remains BACKFILL_MAX_PAGES per request.
1093
- for (let page = 0; page < BACKFILL_MAX_PAGES; page++) {
1094
- const target = nextIncomplete();
1095
- if (!target) break;
1096
- try {
1097
- await backfillSession(target);
1098
- } catch (error) {
1099
- // Partial-result scope is this request's pageRows() only: any already
1100
- // visible covered or provisional row keeps the page usable and the
1101
- // incomplete cursor retryable. An empty current page still throws.
1102
- if (rows.length === 0) throw error;
1181
+ const row = editable(assistantID);
1182
+ const limit = input.limit == null ? 50 : Number(input.limit);
1183
+ if (!Number.isInteger(limit) || limit < 1 || limit > 100) fail('validation_error');
1184
+ const before = decodeCursor(input.before);
1185
+ const bindings = listHistoryBindings(row);
1186
+ // Newest bindings first; within a binding keep official projection order.
1187
+ const scanOrder = [...bindings].sort((left, right) => right.ordinal - left.ordinal);
1188
+ let startIndex = 0;
1189
+ if (before != null) {
1190
+ const found = scanOrder.findIndex((binding) => binding.ordinal === before.sessionOrdinal);
1191
+ // Binding disappeared (archive GC) — resume at the nearest older binding.
1192
+ startIndex = found >= 0 ? found : scanOrder.findIndex((binding) => binding.ordinal < before.sessionOrdinal);
1193
+ if (startIndex < 0) startIndex = scanOrder.length;
1194
+ }
1195
+ const collected = [];
1196
+ const failedScope = [];
1197
+ let anySuccess = false;
1198
+ let pagesUsed = 0;
1199
+ let nextState = null;
1200
+
1201
+ for (let index = startIndex; index < scanOrder.length; index++) {
1202
+ if (collected.length > limit) break;
1203
+ if (pagesUsed >= BACKFILL_MAX_PAGES) {
1204
+ const binding = scanOrder[index];
1205
+ nextState = {
1206
+ sessionOrdinal: binding.ordinal,
1207
+ upstream: index === startIndex && before ? before.upstream : null,
1208
+ skip: index === startIndex && before ? before.skip : 0,
1209
+ };
1103
1210
  break;
1104
1211
  }
1105
- rows = pageRows();
1106
- }
1107
- const page = rows.slice(0, limit); const oldest = page[page.length - 1]; const remaining = nextIncomplete(oldest?.session_ordinal ?? before?.scanSessionOrdinal ?? before?.sessionOrdinal ?? null); const nextCursor = oldest ? (rows.length > limit || remaining ? encodeCursor(oldest, oldest.session_ordinal) : null) : remaining ? encodeCursor({ session_ordinal: remaining.ordinal, message_ordinal: Number.MAX_SAFE_INTEGER, message_id: '\uffff' }, remaining.ordinal) : null;
1108
- let ordered = [...page].reverse().map((message) => ({ sessionID: message.session_id, directory: message.directory, info: parse(message.info_json), parts: db.prepare('SELECT part_json FROM assistant_message_part_mirror WHERE assistant_id=? AND session_id=? AND message_id=? ORDER BY ordinal ASC,part_id ASC').all(row.assistant_id, message.session_id, message.message_id).map((part) => parse(part.part_json)) }));
1109
- // Current binding may still be live-only: if the mirror page has no
1110
- // current-session rows, read the v2 projection instead of returning empty.
1111
- if (page.length === 0 && row.current_session_id && !currentIsArchived) {
1112
- try {
1113
- const loaded = await loadProjectionSession({ assistant_id: row.assistant_id, session_id: row.current_session_id, directory: effectiveWorkspace(row), ordinal: currentSessionOrdinal }, limit);
1114
- const live = [];
1115
- for (const entry of loaded.entries) {
1116
- const info = entry?.info ?? entry;
1117
- if (!plainObject(info) || !nonEmptyString(info.id)) continue;
1118
- if (nonEmptyString(info.sessionID) && info.sessionID !== row.current_session_id) continue;
1119
- live.push({
1120
- sessionID: row.current_session_id,
1121
- directory: loaded.directory,
1122
- info,
1123
- parts: Array.isArray(entry?.parts) ? entry.parts : [],
1124
- });
1125
- }
1126
- ordered = [...live, ...ordered];
1127
- } catch {
1128
- // Keep archived/mirror rows; current live projection is best-effort.
1212
+ const binding = scanOrder[index];
1213
+ const resumeHere = index === startIndex && before != null;
1214
+ const loaded = await loadProjectionBinding(binding, {
1215
+ upstream: resumeHere ? before.upstream : null,
1216
+ skip: resumeHere ? before.skip : 0,
1217
+ need: limit + 1 - collected.length,
1218
+ maxPages: BACKFILL_MAX_PAGES - pagesUsed,
1219
+ pageLimit: limit,
1220
+ });
1221
+ pagesUsed += loaded.pagesUsed;
1222
+ if (loaded.failed) {
1223
+ failedScope.push({
1224
+ sessionID: binding.session_id,
1225
+ sessionOrdinal: binding.ordinal,
1226
+ status: loaded.failedStatus ?? 500,
1227
+ });
1228
+ // Do not advance past a failed binding — cursor stays retryable here.
1229
+ // Newer successes already in `collected` are still delivered.
1230
+ nextState = {
1231
+ sessionOrdinal: binding.ordinal,
1232
+ upstream: loaded.resumeUpstream,
1233
+ skip: loaded.resumeSkip,
1234
+ };
1235
+ break;
1236
+ }
1237
+ if (loaded.missing) {
1238
+ anySuccess = true;
1239
+ continue;
1129
1240
  }
1241
+ anySuccess = true;
1242
+ collected.push(...loaded.entries);
1243
+ if (collected.length > limit) {
1244
+ // Overshot: drop the probe row and resume at the page boundary of the last binding load.
1245
+ nextState = {
1246
+ sessionOrdinal: binding.ordinal,
1247
+ upstream: loaded.resumeUpstream,
1248
+ skip: loaded.resumeSkip,
1249
+ };
1250
+ break;
1251
+ }
1252
+ if (!loaded.exhausted) {
1253
+ nextState = {
1254
+ sessionOrdinal: binding.ordinal,
1255
+ upstream: loaded.resumeUpstream,
1256
+ skip: loaded.resumeSkip,
1257
+ };
1258
+ break;
1259
+ }
1260
+ // Binding exhausted — fall through to the next older binding.
1261
+ }
1262
+
1263
+ // Total failure must not masquerade as authoritative empty success.
1264
+ if (collected.length === 0 && failedScope.length > 0 && !anySuccess) fail('upstream_error');
1265
+
1266
+ const partial = failedScope.length > 0;
1267
+ // collected is newest-first (binding order + desc projection). Public page is ascending.
1268
+ const pageDesc = collected.slice(0, limit);
1269
+ let nextCursor = nextState != null ? encodeCursor(nextState) : null;
1270
+ if (nextCursor == null && partial && failedScope[0]) {
1271
+ nextCursor = encodeCursor({
1272
+ sessionOrdinal: failedScope[0].sessionOrdinal,
1273
+ upstream: null,
1274
+ skip: 0,
1275
+ });
1130
1276
  }
1131
- return { entries: ordered, nextCursor, complete: nextCursor === null };
1277
+ // complete only when nothing remains and no partial failure left to retry.
1278
+ const complete = nextCursor == null && !partial;
1279
+ const ordered = [...pageDesc].reverse().map((entry) => ({
1280
+ sessionID: entry.session_id,
1281
+ directory: entry.directory,
1282
+ info: entry.info,
1283
+ parts: entry.parts,
1284
+ }));
1285
+ return {
1286
+ entries: ordered,
1287
+ nextCursor: complete ? null : nextCursor,
1288
+ complete,
1289
+ partial,
1290
+ ...(partial ? { failed: failedScope } : {}),
1291
+ };
1132
1292
  };
1133
- for (const current of db.prepare('SELECT * FROM assistant_v2 WHERE current_session_id IS NOT NULL AND tombstone_at IS NULL').all()) void backfillSession({ assistant_id: current.assistant_id, session_id: current.current_session_id, directory: effectiveWorkspace(current) }).catch(() => {});
1134
1293
  const unsubscribeEvents = typeof globalEventHub?.subscribeEvent === 'function' ? globalEventHub.subscribeEvent(processEvent) : null;
1135
1294
  const createAssistant = (input) => { const allowed = new Set(['enabled', 'name', 'defaultPrompt', 'providerID', 'modelID', 'agent', 'variant', 'mode', 'workspacePath']); if (!plainObject(input) || Object.keys(input).some((key) => !allowed.has(key))) fail('validation_error'); const mode = input.mode == null ? 'continuous' : input.mode === 'stateless' || input.mode === 'continuous' ? input.mode : fail('validation_error'); const assistantID = id(); const workspacePath = input.workspacePath == null ? null : workspace(input.workspacePath, assistantID); effectiveWorkspace({ assistant_id: assistantID, workspace_path: workspacePath }); const at = now(); db.exec('BEGIN IMMEDIATE'); try { if (Number(db.prepare('SELECT COUNT(*) AS count FROM assistant_v2 WHERE tombstone_at IS NULL').get().count) >= 100) fail('assistant_limit'); db.prepare('INSERT INTO assistant_v2 (assistant_id,revision,enabled,name,default_prompt,workspace_path,provider_id,model_id,agent,variant,mode,current_session_id,session_generation,created_at,updated_at,tombstone_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)').run(assistantID, 1, input.enabled === false ? 0 : 1, string(input.name, 256, true), input.defaultPrompt ? string(input.defaultPrompt, 200_000) : '', workspacePath, string(input.providerID, 256, true), string(input.modelID, 256, true), input.agent == null ? null : string(input.agent, 256), input.variant == null ? null : string(input.variant, 256), mode, null, 0, at, at, null); bump(); db.exec('COMMIT'); return output(assistant(assistantID)); } catch (error) { db.exec('ROLLBACK'); throw error; } };
1136
1295
  const updateAssistant = async (assistantID, input) => { const row = editable(assistantID); const allowed = new Set(['expectedRevision', 'enabled', 'name', 'defaultPrompt', 'providerID', 'modelID', 'agent', 'variant', 'mode', 'workspacePath']); if (!plainObject(input) || !Number.isInteger(input.expectedRevision) || Object.keys(input).some((key) => !allowed.has(key))) fail('validation_error'); const next = { enabled: input.enabled === undefined ? row.enabled : input.enabled ? 1 : 0, name: input.name === undefined ? row.name : string(input.name, 256, true), prompt: input.defaultPrompt === undefined ? row.default_prompt : string(input.defaultPrompt, 200_000), provider: input.providerID === undefined ? row.provider_id : string(input.providerID, 256, true), model: input.modelID === undefined ? row.model_id : string(input.modelID, 256, true), agent: input.agent === undefined ? row.agent : input.agent === null ? null : string(input.agent, 256), variant: input.variant === undefined ? row.variant : input.variant === null ? null : string(input.variant, 256), mode: input.mode === undefined ? (row.mode === 'stateless' ? 'stateless' : 'continuous') : input.mode === 'continuous' || input.mode === 'stateless' ? input.mode : fail('validation_error'), workspacePath: input.workspacePath === undefined ? row.workspace_path : input.workspacePath == null ? null : workspace(input.workspacePath, assistantID) }; const nextRow = { ...row, workspace_path: next.workspacePath, name: next.name }; effectiveWorkspace(nextRow); const workspaceChanged = next.workspacePath !== row.workspace_path; if (workspaceChanged && row.current_session_id) archiveSession(assistantID, row.current_session_id); const created = workspaceChanged ? await createSession(nextRow) : null; const result = db.prepare('UPDATE assistant_v2 SET enabled=?,name=?,default_prompt=?,provider_id=?,model_id=?,agent=?,variant=?,mode=?,workspace_path=?,current_session_id=?,session_generation=session_generation+?,revision=revision+1,updated_at=? WHERE assistant_id=? AND revision=? AND session_generation=? AND tombstone_at IS NULL').run(next.enabled, next.name, next.prompt, next.provider, next.model, next.agent, next.variant, next.mode, next.workspacePath, created?.sessionID ?? row.current_session_id, workspaceChanged ? 1 : 0, now(), assistantID, input.expectedRevision, row.session_generation); if (!result.changes) fail('revision_conflict'); bump(); return output(assistant(assistantID)); };
@@ -1149,10 +1308,8 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
1149
1308
  const target = active(row.assistant_id);
1150
1309
  const targetConfig = configuration(target);
1151
1310
  result = await sendPrompt(restored.sessionID, targetConfig);
1152
- if (promptAdmitted(result)) mirrorAdmittedUserMessage(target, restored.sessionID, messageID, parts, targetConfig);
1153
1311
  return { result, binding: restored };
1154
1312
  }
1155
- if (promptAdmitted(result)) mirrorAdmittedUserMessage(row, sessionID, messageID, parts, config);
1156
1313
  return { result, binding: binding(row) };
1157
1314
  };
1158
1315
  const extractUserText = (input) => {
@@ -1823,14 +1980,17 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
1823
1980
  const { signal } = params;
1824
1981
  signal?.throwIfAborted();
1825
1982
  const resolved = await resolveReferencedSession({ sessionID: params.sessionID, signal, includeMessages: false });
1983
+ // First page: order=desc. Continuation: opaque cursor only (upstream 400s on both).
1826
1984
  const result = await invokeSession(() => client().message.list({
1827
- sessionID: resolved.sessionID, limit, order: 'desc',
1828
- ...(params.before ? { cursor: params.before } : {}),
1985
+ sessionID: resolved.sessionID, limit,
1986
+ ...(params.before ? { cursor: params.before } : { order: 'desc' }),
1829
1987
  }));
1830
1988
  signal?.throwIfAborted();
1831
1989
  if (isMissing(result)) throw new AssignError(ASSIGN_CODES.NOT_FOUND, 'That session no longer exists.');
1832
- if (result?.error || !Array.isArray(result?.data)) throw new AssignError(ASSIGN_CODES.UPSTREAM, 'Could not read session messages.');
1833
- const rows = result.data;
1990
+ const projected = result?.error ? null : projectionEntries(result, resolved.sessionID);
1991
+ if (!projected) throw new AssignError(ASSIGN_CODES.UPSTREAM, 'Could not read session messages.');
1992
+ // Desc pages arrive newest-first; quote them oldest→newest.
1993
+ const rows = projected.slice().reverse();
1834
1994
  if (rows.some((entry) => (entry?.info?.sessionID && entry.info.sessionID !== resolved.sessionID))) throw new AssignError(ASSIGN_CODES.UPSTREAM, 'Session message identity mismatch.');
1835
1995
  let remaining = 24000;
1836
1996
  let partial = rows.length > limit;
@@ -1856,8 +2016,7 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
1856
2016
  }
1857
2017
  return { messageID: info.id, role: info.role, parts };
1858
2018
  });
1859
- const cursor = result.response?.headers?.get('x-next-cursor');
1860
- const nextCursor = typeof cursor === 'string' && cursor ? cursor : null;
2019
+ const nextCursor = projectionCursor(result);
1861
2020
  return { sessionID: resolved.sessionID, directory: resolved.directory, title: resolved.title.slice(0, 500), messages, nextCursor, partial: partial || nextCursor !== null };
1862
2021
  };
1863
2022
  const assignWork = async (row, params = {}) => {
@@ -2148,10 +2307,16 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
2148
2307
  return { sessionID, directory: resolved.directory, operation, messageID, admitted: true };
2149
2308
  }
2150
2309
  if (operation === 'archive') {
2151
- if (typeof api.session.update !== 'function') {
2310
+ // OC2: Host owns archive stamps (openchamber.archive.archivedAt). Do not
2311
+ // call upstream session.update time.archived — it is unavailable.
2312
+ if (typeof archiveSessionHost !== 'function') {
2152
2313
  throw new AssignError(ASSIGN_CODES.UPSTREAM, 'Session archive is unavailable.');
2153
2314
  }
2154
- result = await invokeSession(() => api.session.update({ sessionID, directory: resolved.directory, time: { archived: now() } }));
2315
+ result = await archiveSessionHost({
2316
+ sessionID,
2317
+ directory: resolved.directory,
2318
+ archivedAt: now(),
2319
+ });
2155
2320
  } else if (operation === 'delete') {
2156
2321
  if (typeof api.session.remove !== 'function') {
2157
2322
  throw new AssignError(ASSIGN_CODES.UPSTREAM, 'Session delete is unavailable.');
@@ -2164,12 +2329,33 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
2164
2329
  if (error instanceof AssignError) throw error;
2165
2330
  throw new AssignError(ASSIGN_CODES.UPSTREAM, error?.message || `Session ${operation} failed.`);
2166
2331
  }
2332
+ if (operation === 'archive') {
2333
+ if (!result?.session?.id) {
2334
+ throw new AssignError(ASSIGN_CODES.UPSTREAM, `Session ${operation} was not confirmed. Do not resend automatically.`);
2335
+ }
2336
+ reportAssignedSession(sessionID, 'cancelled', false);
2337
+ return { sessionID, directory: resolved.directory, operation, archived: true };
2338
+ }
2167
2339
  if (isMissing(result)) throw new AssignError(ASSIGN_CODES.NOT_FOUND, `No coding session ${sessionID} was found.`);
2168
2340
  if (result?.error || result?.data === false) {
2169
2341
  throw new AssignError(ASSIGN_CODES.UPSTREAM, `Session ${operation} was not confirmed. Do not resend automatically.`);
2170
2342
  }
2343
+ // Successful delete: direct idempotent Host metadata cleanup; session.deleted
2344
+ // events remain compensatory. Upstream failure never reaches here.
2345
+ if (typeof forgetSessionHost === 'function') {
2346
+ try {
2347
+ const cleanup = forgetSessionHost(sessionID);
2348
+ if (cleanup && typeof cleanup.catch === 'function') {
2349
+ cleanup.catch((error) => {
2350
+ console.warn('[assistants] metadata cleanup after delete failed (retry via event):', error?.message ?? error);
2351
+ });
2352
+ }
2353
+ } catch (error) {
2354
+ console.warn('[assistants] metadata cleanup after delete failed (retry via event):', error?.message ?? error);
2355
+ }
2356
+ }
2171
2357
  reportAssignedSession(sessionID, 'cancelled', false);
2172
- return { sessionID, directory: resolved.directory, operation, ...(operation === 'archive' ? { archived: true } : { deleted: true }) };
2358
+ return { sessionID, directory: resolved.directory, operation, deleted: true };
2173
2359
  };
2174
2360
  const matchRegisteredProject = async (directory) => {
2175
2361
  const projects = await listProjects();
@@ -3523,7 +3709,7 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
3523
3709
  )),
3524
3710
  };
3525
3711
  };
3526
- return { capability: async () => ({ supported: true, enabled: enabled(), revision: revision(), serverInstanceID: await getServerId(), sharingAvailable: false, archiveMetadataAvailable: false, sessionMetadataAvailable: false }), snapshot, createAssistant, updateAssistant, setEnabled: (input) => { if (!plainObject(input) || typeof input.enabled !== 'boolean' || input.expectedRevision !== revision()) fail('revision_conflict'); db.prepare("UPDATE assistant_meta SET value=? WHERE key='enabled'").run(input.enabled ? '1' : '0'); return { enabled: input.enabled, revision: bump() }; }, removeAssistant: (assistantID, expectedRevision) => {
3712
+ return { capability: async () => ({ supported: true, enabled: enabled(), revision: revision(), serverInstanceID: await getServerId(), sharingAvailable: false, archiveMetadataAvailable: typeof archiveSessionHost === 'function', sessionMetadataAvailable: false }), snapshot, createAssistant, updateAssistant, setEnabled: (input) => { if (!plainObject(input) || typeof input.enabled !== 'boolean' || input.expectedRevision !== revision()) fail('revision_conflict'); db.prepare("UPDATE assistant_meta SET value=? WHERE key='enabled'").run(input.enabled ? '1' : '0'); return { enabled: input.enabled, revision: bump() }; }, removeAssistant: (assistantID, expectedRevision) => {
3527
3713
  db.exec('BEGIN IMMEDIATE');
3528
3714
  try {
3529
3715
  const result = db.prepare('UPDATE assistant_v2 SET tombstone_at=?,revision=revision+1,updated_at=? WHERE assistant_id=? AND revision=? AND tombstone_at IS NULL').run(now(), now(), assistantID, expectedRevision);