@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
@@ -2,9 +2,8 @@ import crypto from 'node:crypto';
2
2
  import fs from 'node:fs';
3
3
  import { createRequire } from 'node:module';
4
4
  import path from 'node:path';
5
- import { createOpencodeClient } from '@opencode-ai/sdk/v2';
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;
@@ -117,22 +117,98 @@ export class AssistantError extends Error {
117
117
  const fail = (code, message) => { throw new AssistantError(code, message); };
118
118
  const string = (value, max = 10_000, required = false) => { if (value == null && !required) return null; if (typeof value !== 'string' || value.length > max || (required && !value.trim())) fail('validation_error'); return value.trim(); };
119
119
  const nonEmptyString = (value, max = 10_000) => typeof value === 'string' && value.length > 0 && value.length <= max;
120
- const isMissing = (result) => result?.error?.status === 404 || result?.error?.statusCode === 404 || result?.error?.code === 'not_found' || result?.status === 404;
120
+ const isMissing = (result) => {
121
+ if (!result || typeof result !== 'object') return false;
122
+ if (result.error?.status === 404 || result.error?.statusCode === 404 || result.status === 404) return true;
123
+ const code = result.error?.code ?? result.error?.type ?? result.error?._tag ?? result._tag ?? result.code;
124
+ return code === 'not_found' || code === 'SessionNotFoundError' || code === 'MessageNotFoundError';
125
+ };
121
126
  const messagesErrorStatus = (result) => { const status = result?.error?.status ?? result?.error?.statusCode ?? result?.status; return Number.isFinite(status) ? status : null; };
127
+ const getHttpStatus = (error) => {
128
+ const candidates = [error?.cause?.status, error?.status, error?.response?.status];
129
+ for (const value of candidates) { if (Number.isFinite(value)) return value; }
130
+ return undefined;
131
+ };
132
+ // Real @opencode/client throws declared JSON with `_tag` (no HTTP status).
133
+ const isMissingError = (error) => {
134
+ if (getHttpStatus(error) === 404 || isMissing(error)) return true;
135
+ if (!error || typeof error !== 'object') return false;
136
+ const tag = error._tag ?? error.name ?? error.code ?? error.type ?? error.error?._tag;
137
+ return tag === 'SessionNotFoundError' || tag === 'MessageNotFoundError' || tag === 'not_found';
138
+ };
139
+ const ASSISTANT_SUCCESS_FINISH = new Set(['stop']);
140
+ const ASSISTANT_ERROR_FINISH = new Set(['error', 'length', 'content-filter']);
141
+ const GOAL_ACTIVE_STATUSES = new Set(['active', 'paused']);
142
+ const GOAL_ERROR_STATUSES = new Set(['blocked', 'budgetLimited']);
122
143
  const isTransientMessagesFailure = (result, error) => {
123
144
  if (error) {
124
145
  if (error instanceof AssistantError) return false;
125
- if (error?.name === 'TypeError' || error?.name === 'FetchError') return true;
146
+ if (error?.reason === 'Transport' || error?.name === 'TypeError' || error?.name === 'FetchError' || error?.name === 'AbortError') return true;
126
147
  const code = error?.cause?.code || error?.code;
127
148
  if (code === 'ECONNREFUSED' || code === 'ENOTFOUND' || code === 'ECONNRESET' || code === 'ETIMEDOUT' || code === 'EPIPE') return true;
128
- return false;
149
+ const status = getHttpStatus(error);
150
+ if (status == null) return false;
151
+ return status === 408 || status === 429 || status >= 500;
129
152
  }
130
153
  const status = messagesErrorStatus(result);
131
154
  if (status == null) return true;
132
155
  return status === 408 || status === 429 || status >= 500;
133
156
  };
134
157
  const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
135
- const promptAdmitted = (result) => !result?.error && (result?.response?.status === 204 || result?.status === 204 || result?.data !== undefined || result?.response?.ok === true);
158
+ const sessionRecord = (value) => (value && typeof value === 'object' && value.data && value.id == null ? value.data : value);
159
+ const sessionIDOf = (value) => sessionRecord(value)?.id ?? null;
160
+ const sessionDirectoryOf = (value) => {
161
+ const session = sessionRecord(value);
162
+ return session?.location?.directory ?? session?.directory ?? session?.project?.worktree ?? null;
163
+ };
164
+ const toV2PromptInput = (sessionID, parts, messageID) => {
165
+ const text = (parts || []).filter((part) => part?.type === 'text').map((part) => part.text).join('\n');
166
+ const files = (parts || []).filter((part) => part?.type === 'file').map((part) => ({ uri: part.url, ...(part.filename ? { name: part.filename } : {}) }));
167
+ const agents = (parts || []).filter((part) => part?.type === 'agent').map((part) => ({ name: part.name }));
168
+ return { sessionID, id: messageID, text, ...(files.length ? { files } : {}), ...(agents.length ? { agents } : {}), delivery: 'steer' };
169
+ };
170
+ const projectV2Parts = (entry, sessionID) => {
171
+ if (Array.isArray(entry?.parts)) return entry.parts;
172
+ const messageID = entry?.id;
173
+ const parts = [];
174
+ if (typeof entry?.text === 'string' && entry.text) parts.push({ id: `${messageID}:text`, sessionID, messageID, type: 'text', text: entry.text });
175
+ if (Array.isArray(entry?.files)) entry.files.forEach((file, index) => parts.push({ id: `${messageID}:file:${index}`, sessionID, messageID, type: 'file', mime: file?.mime || 'application/octet-stream', url: file?.uri ?? file?.url, ...(file?.name ? { filename: file.name } : {}) }));
176
+ if (Array.isArray(entry?.content)) entry.content.forEach((item, index) => {
177
+ const partID = item?.id || `${messageID}:content:${index}`;
178
+ if (item?.type === 'text') parts.push({ id: partID, sessionID, messageID, type: 'text', text: item.text ?? '' });
179
+ else if (item?.type === 'reasoning') parts.push({ id: partID, sessionID, messageID, type: 'reasoning', text: item.text ?? '' });
180
+ else if (item?.type === 'tool') parts.push({ id: partID, sessionID, messageID, type: 'tool', tool: item.name, callID: item.id, state: item.state ?? {} });
181
+ });
182
+ return parts;
183
+ };
184
+ const projectProjectionEntry = (entry, sessionID) => {
185
+ if (plainObject(entry?.info)) return { info: entry.info, parts: Array.isArray(entry.parts) ? entry.parts : [] };
186
+ if (!plainObject(entry) || !nonEmptyString(entry.id)) return { info: entry, parts: [] };
187
+ const role = entry.type === 'user' || entry.type === 'assistant' ? entry.type : (entry.role ?? entry.type);
188
+ return { info: { id: entry.id, sessionID: nonEmptyString(entry.sessionID) ? entry.sessionID : sessionID, role, time: entry.time ?? { created: 0 }, ...entry }, parts: projectV2Parts(entry, nonEmptyString(entry.sessionID) ? entry.sessionID : sessionID) };
189
+ };
190
+ const projectionEntries = (payload, sessionID) => {
191
+ const items = Array.isArray(payload?.data) ? payload.data : Array.isArray(payload) ? payload : Array.isArray(payload?.data?.items) ? payload.data.items : null;
192
+ if (!items) return null;
193
+ return items.map((entry) => projectProjectionEntry(entry, sessionID));
194
+ };
195
+ const projectionCursor = (result) => { const next = typeof result?.cursor === 'string' ? result.cursor : result?.cursor?.next; return (typeof next === 'string' && next) ? next : (result?.response?.headers?.get?.('x-next-cursor') ?? null); };
196
+ const promptAdmitted = (result) => !result?.error && (result?.response?.status === 204 || result?.status === 204 || result?.id != null || result?.type != null || result?.data !== undefined || result?.response?.ok === true);
197
+ const invokeSession = async (work) => {
198
+ try {
199
+ const value = await work();
200
+ if (isMissing(value)) return { error: { status: 404 }, status: 404 };
201
+ if (value?.error) return value;
202
+ return value;
203
+ } catch (error) {
204
+ if (error instanceof AssistantError) throw error;
205
+ if (isMissingError(error)) return { error: { status: 404 }, status: 404 };
206
+ const status = getHttpStatus(error);
207
+ if (status != null) return { error: { status }, status };
208
+ return { error: { status: 500 }, status: 500 };
209
+ }
210
+ };
211
+
136
212
  /** Bound catalog / project list waits so a contact lane cannot hang forever. */
137
213
  export const CONTACT_CATALOG_DEADLINE_MS = 8_000;
138
214
  /** Bound last worker assistant text injected into assigned-session resume prompts. */
@@ -160,7 +236,7 @@ const awaitWithDeadline = async (work, ms = CONTACT_CATALOG_DEADLINE_MS, code =
160
236
  }
161
237
  };
162
238
 
163
- 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, 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 } = {}) => {
164
240
  if (!dbPath || !dataDir) return null;
165
241
  const Database = require('better-sqlite3');
166
242
  fs.mkdirSync(path.dirname(dbPath), { recursive: true });
@@ -244,15 +320,11 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
244
320
  const existing = db.prepare('SELECT directory FROM assistant_session_history WHERE assistant_id=? AND session_id=?').get(assistantID, sessionID);
245
321
  if (existing) {
246
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);
247
- db.prepare('DELETE FROM assistant_message_backfill WHERE assistant_id=? AND session_id=?').run(assistantID, sessionID);
248
- 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);
249
323
  return;
250
324
  }
251
325
  const effectiveDirectory = directory ?? effectiveWorkspace(editable(assistantID));
252
326
  const ordinal = Number(db.prepare('SELECT COALESCE(MAX(ordinal), 0) + 1 AS next FROM assistant_session_history WHERE assistant_id=?').get(assistantID).next);
253
327
  db.prepare('INSERT INTO assistant_session_history(assistant_id, session_id, ordinal, directory, created_at) VALUES (?,?,?,?,?)').run(assistantID, sessionID, ordinal, effectiveDirectory, now());
254
- db.prepare('DELETE FROM assistant_message_backfill WHERE assistant_id=? AND session_id=?').run(assistantID, sessionID);
255
- 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);
256
328
  };
257
329
  const activeContactTurnsFor = (assistantID) => activeContactTurnsByAssistant.get(assistantID) ?? new Map();
258
330
  const rememberActiveContactTurn = (assistantID, input) => {
@@ -348,30 +420,43 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
348
420
  };
349
421
  };
350
422
  const binding = (row) => ({ sessionID: row.current_session_id, directory: effectiveWorkspace(row), sessionGeneration: row.session_generation });
351
- const client = () => clientFactory ? clientFactory() : createOpencodeClient({ baseUrl: buildOpenCodeUrl('/', '').replace(/\/$/, ''), headers: getOpenCodeAuthHeaders() });
352
- const metadata = (row) => ({ openchamber: { assistant: { assistantID: row.assistant_id, name: row.name } } });
353
- const ARCHIVE_RETRY_MS = 25;
354
- const archiveCreatedSession = async (sessionID, directory) => {
355
- const archiveAt = now();
356
- const update = () => client().session.update({ sessionID, directory, time: { archived: archiveAt } });
357
- let result = await update();
358
- if (isMissing(result)) {
359
- await sleep(ARCHIVE_RETRY_MS);
360
- result = await update();
361
- }
362
- if (result?.error || isMissing(result)) fail('upstream_error');
423
+ const client = () => clientFactory
424
+ ? clientFactory()
425
+ : makeOpenCodeV2Client({
426
+ baseUrl: buildOpenCodeUrl('/', '').replace(/\/$/, ''),
427
+ authHeaders: getOpenCodeAuthHeaders(),
428
+ });
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
+ }
363
441
  };
364
442
  const createSession = async (row) => {
365
443
  const directory = effectiveWorkspace(row);
366
- const result = await client().session.create({ directory, title: `[Assistant] ${row.name}`, metadata: metadata(row) });
367
- if (result.error || !result.data?.id) fail('upstream_error');
368
- const sessionID = result.data.id;
444
+ const isolation = metadata(row);
445
+ const result = await invokeSession(() => client().session.create({
446
+ title: `[Assistant] ${row.name}`,
447
+ location: { directory },
448
+ metadata: isolation,
449
+ ...(row.agent ? { agent: row.agent } : {}),
450
+ ...(row.provider_id && row.model_id ? { model: { id: row.model_id, providerID: row.provider_id, ...(row.variant ? { variant: row.variant } : {}) } } : {}),
451
+ }));
452
+ const sessionID = sessionIDOf(result);
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);
369
456
  // Archive before binding so ordinary session lists never flash system sessions.
370
- // Metadata still isolates the session if archive fails after create.
371
- await archiveCreatedSession(sessionID, directory);
372
457
  return { sessionID, directory };
373
458
  };
374
- const sessionExists = async (row) => { if (!row.current_session_id) return false; const result = await client().session.get({ sessionID: row.current_session_id, directory: effectiveWorkspace(row) }); if (isMissing(result)) return false; if (result.error) fail('upstream_error'); return Boolean(result.data); };
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); };
375
460
  const replaceBinding = (row, created) => { if (row.current_session_id && row.current_session_id !== created.sessionID) archiveSession(row.assistant_id, row.current_session_id, effectiveWorkspace(row)); const result = db.prepare('UPDATE assistant_v2 SET current_session_id=?,session_generation=session_generation+1,updated_at=? WHERE assistant_id=? AND session_generation=? AND tombstone_at IS NULL').run(created.sessionID, now(), row.assistant_id, row.session_generation); if (result.changes) bump(); return result.changes ? assistant(row.assistant_id) : null; };
376
461
  const statelessLanes = new Map();
377
462
  const inStatelessLane = (assistantID, task) => {
@@ -413,75 +498,21 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
413
498
  db.prepare("INSERT OR REPLACE INTO assistant_meta(key,value) VALUES ('schema_version',?)").run(String(SCHEMA_VERSION));
414
499
  };
415
500
  migrate();
416
- const mirrorMessage = (assistantID, sessionID, info, ordinal, covered = false) => {
417
- const messageID = info?.id;
418
- if (!nonEmptyString(assistantID) || !nonEmptyString(sessionID) || !nonEmptyString(messageID) || !plainObject(info)) return;
419
- const existing = db.prepare('SELECT ordinal FROM assistant_message_mirror WHERE assistant_id=? AND session_id=? AND message_id=?').get(assistantID, sessionID, messageID);
420
- 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);
421
- 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());
422
- };
423
- const mirrorPart = (assistantID, sessionID, part, ordinal) => {
424
- const messageID = part?.messageID; const partID = part?.id;
425
- if (!nonEmptyString(assistantID) || !nonEmptyString(sessionID) || !nonEmptyString(messageID) || !nonEmptyString(partID) || !plainObject(part)) return;
426
- 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);
427
- 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);
428
- 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());
429
- };
430
- const mirrorAdmittedUserMessage = (row, sessionID, messageID, parts, config) => {
431
- 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);
432
- const existingInfo = existingMessage ? parse(existingMessage.info_json) : null;
433
- const info = plainObject(existingInfo) && existingInfo.role === 'user'
434
- ? existingInfo
435
- : {
436
- id: messageID,
437
- sessionID,
438
- role: 'user',
439
- time: { created: now() },
440
- ...(config?.agent ? { agent: config.agent } : {}),
441
- ...(plainObject(config?.model) ? { model: config.model } : {}),
442
- ...(typeof config?.system === 'string' && config.system ? { system: config.system } : {}),
443
- summary: { diffs: [] },
444
- openchamberAssistantAdmission: true,
445
- };
446
- db.exec('BEGIN IMMEDIATE');
447
- try {
448
- mirrorMessage(row.assistant_id, sessionID, info, undefined, true);
449
- 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;
450
- if (!hasAuthoritativeParts) {
451
- 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);
452
- parts.forEach((part, index) => mirrorPart(row.assistant_id, sessionID, {
453
- ...part,
454
- id: `oc_asst_admission:${index + 1}`,
455
- sessionID,
456
- messageID,
457
- }, index + 1));
458
- }
459
- db.exec('COMMIT');
460
- } catch (error) {
461
- db.exec('ROLLBACK');
462
- throw error;
463
- }
464
- bump();
465
- };
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.
466
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);
467
- const invalidateCoverage = (assistantID, sessionID) => {
468
- db.prepare('UPDATE assistant_message_mirror SET covered=0 WHERE assistant_id=? AND session_id=?').run(assistantID, sessionID);
469
- 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());
470
- };
471
- // Reopen demand backfill without clearing covered/provisional mirrors.
472
- const invalidateBackfill = (assistantID, sessionID) => {
473
- const current = db.prepare('SELECT cursor,complete FROM assistant_message_backfill WHERE assistant_id=? AND session_id=?').get(assistantID, sessionID);
474
- const next = reduceBackfillState({ cursor: current?.cursor ?? null, complete: Boolean(current?.complete) }, 'invalidate');
475
- 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());
476
- };
477
- // Structural part deletes may leave a covered message incomplete; re-demand that session only.
478
- // Do not blanket-uncover on ordinary message/part upserts — that blanks served history until re-backfill.
479
- const invalidateMessageCoverage = (assistantID, sessionID, messageID) => {
480
- db.prepare('UPDATE assistant_message_mirror SET covered=0 WHERE assistant_id=? AND session_id=? AND message_id=?').run(assistantID, sessionID, messageID);
481
- 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());
482
- };
483
- const eventSessionID = (properties) => {
484
- const sessionID = properties?.sessionID || properties?.sessionId || properties?.info?.sessionID || properties?.info?.sessionId;
505
+ // Accept bridge/legacy `{ properties }` and native v2 `{ data }` envelopes.
506
+ const eventBody = (payload) => {
507
+ if (!plainObject(payload)) return null;
508
+ const data = plainObject(payload.data) ? payload.data : null;
509
+ const properties = plainObject(payload.properties) ? payload.properties : null;
510
+ if (data && Object.keys(data).length > 0) return data;
511
+ if (properties) return properties;
512
+ return data || properties || null;
513
+ };
514
+ const eventSessionID = (body) => {
515
+ const sessionID = body?.sessionID || body?.sessionId || body?.info?.sessionID || body?.info?.sessionId;
485
516
  return nonEmptyString(sessionID) ? sessionID : '';
486
517
  };
487
518
  const isUserAbort = (error) => error?.name === 'MessageAbortedError';
@@ -519,21 +550,44 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
519
550
  if (status === 'cancelled') entry.observed.resume = resume !== false;
520
551
  }
521
552
  };
522
- const settleStatusFromEvent = (payload, properties) => {
523
- if (payload.type === 'session.error') return isUserAbort(properties.error) ? 'cancelled' : 'error';
524
- if (payload.type === 'question.asked' || payload.type === 'permission.asked') return 'question';
525
- if (payload.type === 'session.idle') return 'complete';
526
- if (payload.type === 'session.status') {
527
- const statusType = typeof properties.status?.type === 'string' ? properties.status.type : typeof properties.info?.type === 'string' ? properties.info.type : '';
528
- if (statusType === 'busy' || statusType === 'retry') return 'busy';
553
+ const settleStatusFromEvent = (payload, body) => {
554
+ const type = typeof payload?.type === 'string' ? payload.type : '';
555
+ if (type === 'session.error' || type === 'session.execution.failed') {
556
+ return isUserAbort(body?.error) ? 'cancelled' : 'error';
557
+ }
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.
561
+ if (type === 'session.execution.interrupted') {
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';
566
+ }
567
+ if (type === 'session.execution.succeeded') return 'complete';
568
+ if (type === 'session.execution.started' || type === 'session.retry.scheduled') return 'busy';
569
+ if (type === 'question.asked' || type === 'permission.asked'
570
+ || type === 'session.pending.question' || type === 'session.pending.permission'
571
+ || type === 'pending.question' || type === 'pending.permission') return 'question';
572
+ if (type === 'session.idle') return 'complete';
573
+ if (type === 'session.status') {
574
+ const statusType = typeof body?.status?.type === 'string' ? body.status.type : typeof body?.info?.type === 'string' ? body.info.type : '';
575
+ if (statusType === 'busy' || statusType === 'retry' || statusType === 'running') return 'busy';
529
576
  if (statusType === 'idle') return 'complete';
530
577
  }
531
578
  return null;
532
579
  };
580
+ // Stash assign delivery message IDs until the session card watch is written.
581
+ const pendingAssignMessageIDs = new Map();
533
582
  // Assigned-session complete/error/cancelled resumes the contact LLM (no canned settle bubble).
534
583
  // Assigned after inContactTurnLane exists; reportAssignedSession only schedules via this ref.
535
584
  const assignedSessionResumeRef = { schedule: null };
536
- const reportAssignedSession = (sessionID, status, resume = true) => {
585
+ const reportAssignedSession = (sessionID, status, resumeOrOpts = true) => {
586
+ const opts = plainObject(resumeOrOpts)
587
+ ? resumeOrOpts
588
+ : { resume: resumeOrOpts !== false };
589
+ const resume = opts.resume !== false;
590
+ const requireExecutionOwned = opts.requireExecutionOwned === true;
537
591
  const inflight = sessionAbortInflight.get(sessionID);
538
592
  if (inflight) {
539
593
  // Defer watch terminal + continuation until stop_session abort settles.
@@ -554,6 +608,9 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
554
608
  if (watch.status === status || watch.status === 'cancelled') continue;
555
609
  // session.idle / status idle follow session.error. Do not rewrite 失败 as 完成.
556
610
  if (status === 'complete' && watch.status === 'error') continue;
611
+ // Weak idle signals must not settle a delivery-watermarked watch — old
612
+ // session.idle can race a reuse assign. Formal execution.* owns that path.
613
+ if (requireExecutionOwned && status === 'complete' && watch.messageID) continue;
557
614
  const updatedAt = now();
558
615
  updateSessionCardStatus(db, { assistantID: watch.assistantID, sessionID, status });
559
616
  upsertContactWatch(db, {
@@ -563,6 +620,7 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
563
620
  status,
564
621
  updatedAt,
565
622
  resumeAllowed: watch.resumeAllowed,
623
+ ...(watch.messageID ? { messageID: watch.messageID } : {}),
566
624
  });
567
625
  // complete/error/user-interrupt cancelled: hand result back to contact LLM.
568
626
  // question: card only (worker waiting). stop_session passes resume=false.
@@ -586,7 +644,7 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
586
644
  }
587
645
  // processEvent stays sync; resume runs on the contact turn lane asynchronously.
588
646
  if (typeof assignedSessionResumeRef.schedule === 'function') {
589
- for (const resume of resumes) assignedSessionResumeRef.schedule(resume);
647
+ for (const item of resumes) assignedSessionResumeRef.schedule(item);
590
648
  }
591
649
  return changed;
592
650
  };
@@ -596,26 +654,134 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
596
654
  if (typeof session?.type === 'string') return session.type;
597
655
  return '';
598
656
  };
657
+ const readMessageInfo = (entry) => {
658
+ if (!entry || typeof entry !== 'object') return null;
659
+ if (plainObject(entry.info)) return entry.info;
660
+ return entry;
661
+ };
662
+ const isAssistantMessage = (info) => info && (info.type === 'assistant' || info.role === 'assistant');
663
+ const messageListItems = (result) => {
664
+ if (!result || result.error) return null;
665
+ if (Array.isArray(result.data)) return result.data;
666
+ if (Array.isArray(result)) return result;
667
+ if (Array.isArray(result.data?.items)) return result.data.items;
668
+ return null;
669
+ };
670
+ const activeMapOf = (value) => {
671
+ if (!value || typeof value !== 'object' || Array.isArray(value) || value.error) return null;
672
+ // Tolerate `{ data: { ses_x: … } }` wrappers without treating a bare map as nested data.
673
+ if (value.data && typeof value.data === 'object' && !Array.isArray(value.data) && value.id == null
674
+ && !Object.prototype.hasOwnProperty.call(value, 'type')) {
675
+ const nested = value.data;
676
+ if (!Array.isArray(nested) && typeof nested === 'object') return nested;
677
+ }
678
+ return value;
679
+ };
680
+ const goalOfSession = (session) => {
681
+ const goal = session?.metadata?.openchamber?.goal;
682
+ if (!goal || typeof goal !== 'object') return null;
683
+ const status = typeof goal.status === 'string' ? goal.status.trim() : '';
684
+ return status ? { status } : null;
685
+ };
686
+ /** Messages that belong to this delivery watermark (assign prompt messageID). */
687
+ const messagesOwnedByDelivery = (messages, deliveryMessageID) => {
688
+ if (!Array.isArray(messages)) return [];
689
+ if (!nonEmptyString(deliveryMessageID)) return messages;
690
+ const rows = messages.map((entry) => ({ entry, info: readMessageInfo(entry) }));
691
+ const delivery = rows.find((row) => row.info?.id === deliveryMessageID);
692
+ if (delivery) {
693
+ const created = typeof delivery.info?.time?.created === 'number' ? delivery.info.time.created : null;
694
+ return rows
695
+ .filter((row) => {
696
+ if (row.info?.id === deliveryMessageID) return true;
697
+ const rowCreated = typeof row.info?.time?.created === 'number' ? row.info.time.created : null;
698
+ if (created != null && rowCreated != null) {
699
+ if (rowCreated > created) return true;
700
+ if (rowCreated < created) return false;
701
+ }
702
+ // Ascending msg_ ids: strictly after the delivery user row.
703
+ return typeof row.info?.id === 'string' && row.info.id > deliveryMessageID;
704
+ })
705
+ .map((row) => row.entry);
706
+ }
707
+ // Delivery not on this page — only keep ids after the watermark.
708
+ return rows
709
+ .filter((row) => typeof row.info?.id === 'string' && row.info.id > deliveryMessageID)
710
+ .map((row) => row.entry);
711
+ };
599
712
  const lastAssistantInfo = (messages) => {
600
- if (!Array.isArray(messages)) return null;
601
- for (let index = messages.length - 1; index >= 0; index -= 1) {
602
- const info = messages[index]?.info ?? messages[index];
603
- if (info?.role === 'assistant') return info;
713
+ if (!Array.isArray(messages) || messages.length === 0) return null;
714
+ // Prefer newest by time.created; fall back to array order (desc list → index 0).
715
+ let best = null;
716
+ let bestCreated = Number.NEGATIVE_INFINITY;
717
+ let bestIndex = -1;
718
+ for (let index = 0; index < messages.length; index += 1) {
719
+ const info = readMessageInfo(messages[index]);
720
+ if (!isAssistantMessage(info)) continue;
721
+ const created = typeof info?.time?.created === 'number' ? info.time.created : Number.NEGATIVE_INFINITY;
722
+ if (!best || created > bestCreated || (created === bestCreated && index < bestIndex)) {
723
+ best = info;
724
+ bestCreated = created;
725
+ bestIndex = index;
726
+ }
727
+ }
728
+ if (best) return best;
729
+ // Desc page without timestamps: first assistant from the start.
730
+ for (let index = 0; index < messages.length; index += 1) {
731
+ const info = readMessageInfo(messages[index]);
732
+ if (isAssistantMessage(info)) return info;
604
733
  }
605
734
  return null;
606
735
  };
607
- const inferAssignedSessionSettleStatus = (getResult, messagesResult) => {
736
+ const classifyOwnedAssistantTail = (assistantInfo) => {
737
+ if (!assistantInfo) return null;
738
+ if (assistantInfo.error) {
739
+ // MessageAbortedError → cancelled for contact resume; other errors fail the card.
740
+ return isUserAbort(assistantInfo.error) ? 'cancelled' : 'error';
741
+ }
742
+ const finish = typeof assistantInfo.finish === 'string' ? assistantInfo.finish.trim() : '';
743
+ // Mid-loop tool-calls (even with time.completed) are intermediate steps.
744
+ if (finish === 'tool-calls') return null;
745
+ if (ASSISTANT_ERROR_FINISH.has(finish)) return 'error';
746
+ if (assistantInfo.time?.completed && (!finish || ASSISTANT_SUCCESS_FINISH.has(finish))) return 'complete';
747
+ if (!finish && !assistantInfo.time?.completed) return null;
748
+ return null;
749
+ };
750
+ const inferAssignedSessionSettleStatus = (getResult, messagesResult, { deliveryMessageID = null, activeBusy = null } = {}) => {
608
751
  if (isMissing(getResult)) return 'complete';
609
752
  if (getResult?.error) return null;
610
- const session = getResult?.data;
753
+ // Authoritative active membership wins over stale get snapshots.
754
+ if (activeBusy === true) return null;
755
+ const session = sessionRecord(getResult);
611
756
  if (session?.error) return isUserAbort(session.error) ? 'cancelled' : 'error';
757
+ const goal = goalOfSession(session);
758
+ // Goal settle is owned by the session-goal reporter — never invent success.
759
+ if (goal && GOAL_ACTIVE_STATUSES.has(goal.status)) return null;
760
+ if (goal && GOAL_ERROR_STATUSES.has(goal.status)) return 'error';
761
+ if (goal && goal.status === 'complete') return 'complete';
612
762
  const statusType = sessionStatusType(session);
613
- if (statusType === 'busy' || statusType === 'retry') return null;
763
+ if (statusType === 'busy' || statusType === 'retry' || statusType === 'running') return null;
614
764
  if (isMissing(messagesResult)) return 'complete';
615
- const assistant = messagesResult && !messagesResult.error ? lastAssistantInfo(messagesResult.data) : null;
616
- if (assistant?.error) return isUserAbort(assistant.error) ? 'cancelled' : 'error';
617
- if (statusType === 'idle' || session?.time?.completed) return 'complete';
618
- if (assistant?.time?.completed) return 'complete';
765
+ if (messagesResult?.error) return null;
766
+ const items = messageListItems(messagesResult);
767
+ if (!items) return null;
768
+ const owned = messagesOwnedByDelivery(items, deliveryMessageID);
769
+ // Reused session: only pre-delivery assistants remain → wait for this run.
770
+ if (nonEmptyString(deliveryMessageID) && owned.length === 0) return null;
771
+ if (nonEmptyString(deliveryMessageID) && !owned.some((entry) => readMessageInfo(entry)?.id === deliveryMessageID)
772
+ && !owned.some((entry) => isAssistantMessage(readMessageInfo(entry)))) {
773
+ return null;
774
+ }
775
+ const assistantInfo = lastAssistantInfo(owned);
776
+ const fromTail = classifyOwnedAssistantTail(assistantInfo);
777
+ if (fromTail) return fromTail;
778
+ // No owned assistant yet after delivery — keep waiting (superseded / not started).
779
+ if (nonEmptyString(deliveryMessageID) && !assistantInfo) return null;
780
+ // Legacy watches without watermark: idle / session completed may settle.
781
+ if (!nonEmptyString(deliveryMessageID)) {
782
+ if (statusType === 'idle' || session?.time?.completed) return 'complete';
783
+ if (assistantInfo?.time?.completed && assistantInfo.finish !== 'tool-calls') return 'complete';
784
+ }
619
785
  return null;
620
786
  };
621
787
  const resolveWatchDirectory = (watch) => {
@@ -634,26 +800,50 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
634
800
  } catch {
635
801
  return;
636
802
  }
803
+ if (watches.length === 0) return;
804
+ // One active query per reconcile — authoritative busy membership map.
805
+ let activeMap = null;
806
+ let activeKnown = false;
807
+ try {
808
+ const api = client();
809
+ if (typeof api.session?.active === 'function') {
810
+ const raw = await api.session.active();
811
+ activeMap = activeMapOf(raw);
812
+ activeKnown = activeMap != null;
813
+ }
814
+ } catch {
815
+ activeKnown = false;
816
+ }
817
+ if (closed) return;
637
818
  for (const watch of watches) {
638
819
  if (closed) return;
639
820
  try {
821
+ const activeBusy = activeKnown
822
+ ? Object.prototype.hasOwnProperty.call(activeMap, watch.sessionID)
823
+ : null;
824
+ if (activeBusy === true) continue;
640
825
  const directory = resolveWatchDirectory(watch);
641
826
  if (!directory) continue;
642
827
  let getResult;
643
828
  try {
644
- getResult = await client().session.get({ sessionID: watch.sessionID, directory });
645
- } catch {
646
- continue;
829
+ getResult = await invokeSession(() => client().session.get({ sessionID: watch.sessionID, directory }));
830
+ } catch (error) {
831
+ if (isMissingError(error)) getResult = { error: { status: 404 }, status: 404 };
832
+ else continue;
647
833
  }
648
834
  if (closed) return;
649
835
  let messagesResult = null;
650
836
  try {
651
- messagesResult = await client().session.messages({ sessionID: watch.sessionID, directory, limit: 100 });
652
- } catch {
653
- messagesResult = null;
837
+ messagesResult = await invokeSession(() => client().message.list({ sessionID: watch.sessionID, limit: 100, order: 'desc' }));
838
+ } catch (error) {
839
+ if (isMissingError(error)) messagesResult = { error: { status: 404 }, status: 404 };
840
+ else messagesResult = null;
654
841
  }
655
842
  if (closed) return;
656
- const status = inferAssignedSessionSettleStatus(getResult, messagesResult);
843
+ const status = inferAssignedSessionSettleStatus(getResult, messagesResult, {
844
+ deliveryMessageID: watch.messageID,
845
+ activeBusy,
846
+ });
657
847
  if (status) reportAssignedSession(watch.sessionID, status);
658
848
  } catch {
659
849
  // One failed watch must not block unrelated watches.
@@ -662,47 +852,74 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
662
852
  };
663
853
  const processEvent = (event) => {
664
854
  const payload = event?.payload?.payload ?? event?.payload ?? event;
665
- const properties = payload?.properties;
666
- if (!plainObject(payload) || !plainObject(properties)) return false;
855
+ if (!plainObject(payload) || typeof payload.type !== 'string') return false;
856
+ const body = eventBody(payload);
857
+ // Message lifecycle still requires a body; execution/status may carry sessionID on body only.
667
858
  if (payload.type === 'message.updated') {
668
- const info = properties.info; const sessionID = info?.sessionID;
859
+ if (!plainObject(body)) return false;
860
+ const info = body.info; const sessionID = info?.sessionID;
669
861
  if (!nonEmptyString(sessionID) || !plainObject(info)) return false;
670
862
  const cancelled = info.role === 'assistant' && isUserAbort(info.error)
671
863
  ? reportAssignedSession(sessionID, 'cancelled') : false;
672
- 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;
673
866
  }
674
867
  if (payload.type === 'message.part.updated') {
675
- const part = properties.part; const sessionID = properties.sessionID ?? part?.sessionID;
868
+ if (!plainObject(body)) return false;
869
+ const part = body.part; const sessionID = body.sessionID ?? part?.sessionID;
676
870
  if (!nonEmptyString(sessionID) || !plainObject(part)) return false;
677
- 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;
678
872
  }
679
873
  if (payload.type === 'message.removed') {
680
- const sessionID = properties.sessionID; const messageID = properties.messageID;
874
+ if (!plainObject(body)) return false;
875
+ const sessionID = body.sessionID; const messageID = body.messageID;
681
876
  if (!nonEmptyString(sessionID) || !nonEmptyString(messageID)) return false;
682
- 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;
683
878
  }
684
879
  if (payload.type === 'message.part.removed') {
685
- const sessionID = properties.sessionID; const messageID = properties.messageID ?? properties.part?.messageID; const partID = properties.partID ?? properties.part?.id;
880
+ if (!plainObject(body)) return false;
881
+ const sessionID = body.sessionID; const messageID = body.messageID ?? body.part?.messageID; const partID = body.partID ?? body.part?.id;
686
882
  if (!nonEmptyString(sessionID) || !nonEmptyString(messageID) || !nonEmptyString(partID)) return false;
687
- 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;
884
+ }
885
+ const sessionID = plainObject(body) ? eventSessionID(body) : '';
886
+ if (payload.type === 'session.execution.started' || payload.type === 'session.retry.scheduled') {
887
+ if (!sessionID) return false;
888
+ return reportAssignedSession(sessionID, 'busy');
889
+ }
890
+ if (payload.type === 'session.execution.succeeded'
891
+ || payload.type === 'session.execution.failed'
892
+ || payload.type === 'session.execution.interrupted') {
893
+ if (!sessionID) return false;
894
+ const status = settleStatusFromEvent(payload, body);
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
+ }
900
+ const reported = reportAssignedSession(sessionID, status);
901
+ const assistants = mappedAssistants(sessionID);
902
+ return reported || assistants.some((assistantID) => assistant(assistantID)?.current_session_id !== sessionID);
688
903
  }
689
904
  if (payload.type === 'session.idle' || payload.type === 'session.error') {
690
- const sessionID = eventSessionID(properties);
691
905
  if (!sessionID) return false;
692
- const reported = reportAssignedSession(sessionID, settleStatusFromEvent(payload, properties));
693
- const assistants = mappedAssistants(sessionID); for (const assistantID of assistants) { if (assistant(assistantID)?.current_session_id !== sessionID) invalidateBackfill(assistantID, sessionID); }
906
+ // session.idle is a weak fallback: watermarked watches need execution.* or reconcile.
907
+ const status = settleStatusFromEvent(payload, body);
908
+ const reported = reportAssignedSession(sessionID, status, { requireExecutionOwned: payload.type === 'session.idle' });
909
+ const assistants = mappedAssistants(sessionID);
694
910
  return reported || assistants.some((assistantID) => assistant(assistantID)?.current_session_id !== sessionID);
695
911
  }
696
912
  if (payload.type === 'session.status') {
697
- const sessionID = eventSessionID(properties);
698
- const status = settleStatusFromEvent(payload, properties);
699
- if (!sessionID || !status) return false;
700
- const reported = reportAssignedSession(sessionID, status);
701
- const assistants = mappedAssistants(sessionID); for (const assistantID of assistants) { if (assistant(assistantID)?.current_session_id !== sessionID) invalidateBackfill(assistantID, sessionID); }
913
+ if (!sessionID) return false;
914
+ const status = settleStatusFromEvent(payload, body);
915
+ if (!status) return false;
916
+ const reported = reportAssignedSession(sessionID, status, { requireExecutionOwned: status === 'complete' });
917
+ const assistants = mappedAssistants(sessionID);
702
918
  return reported || assistants.some((assistantID) => assistant(assistantID)?.current_session_id !== sessionID);
703
919
  }
704
- if (payload.type === 'question.asked' || payload.type === 'permission.asked') {
705
- const sessionID = eventSessionID(properties);
920
+ if (payload.type === 'question.asked' || payload.type === 'permission.asked'
921
+ || payload.type === 'session.pending.question' || payload.type === 'session.pending.permission'
922
+ || payload.type === 'pending.question' || payload.type === 'pending.permission') {
706
923
  if (!sessionID) return false;
707
924
  return reportAssignedSession(sessionID, 'question');
708
925
  }
@@ -715,131 +932,384 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
715
932
  }
716
933
  return null;
717
934
  };
718
- const clearUncoveredSessionMirror = (assistantID, sessionID) => {
719
- 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);
720
- db.prepare('DELETE FROM assistant_message_mirror WHERE assistant_id=? AND session_id=? AND covered=0').run(assistantID, sessionID);
721
- };
722
- const writeBackfillState = (assistantID, sessionID, next) => {
723
- 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());
724
- };
725
- // Authoritative 404 means this session ID is gone from OpenCode. Converge its
726
- // backfill so one deleted archive cannot block demand scans of other sessions.
727
- // Covered/admitted rows stay; only uncovered event mirrors are dropped. Safe
728
- // under concurrent ensure: a replaced current binding archives under its old
729
- // ID, while a still-current missing ID is recreated with a new session ID.
730
- const completeMissingSessionBackfill = (assistantID, sessionID) => {
731
- 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;
732
939
  try {
733
- const current = db.prepare('SELECT cursor,complete FROM assistant_message_backfill WHERE assistant_id=? AND session_id=?').get(assistantID, sessionID);
734
- const next = reduceBackfillState({ cursor: current?.cursor ?? null, complete: Boolean(current?.complete) }, 'session-missing');
735
- if (next.disposition === 'discard-provisional') clearUncoveredSessionMirror(assistantID, sessionID);
736
- writeBackfillState(assistantID, sessionID, next);
737
- db.exec('COMMIT');
738
- return true;
739
- } catch (error) { db.exec('ROLLBACK'); throw error; }
740
- };
741
- 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) => {
742
963
  for (let attempt = 0; attempt < BACKFILL_MESSAGES_ATTEMPTS; attempt++) {
743
964
  try {
744
- const result = await client().session.messages({ sessionID: history.session_id, ...(directory ? { directory } : {}), limit: BACKFILL_PAGE_SIZE, ...(cursor ? { before: cursor } : {}) });
745
- if (!result?.error || isMissing(result)) return result;
746
- 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
+ }
747
1009
  } catch (error) {
748
1010
  if (error instanceof AssistantError) throw error;
749
- 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
+ }
750
1016
  }
751
1017
  await sleep(BACKFILL_RETRY_MS[Math.min(attempt, BACKFILL_RETRY_MS.length - 1)]);
752
1018
  }
753
- fail('upstream_error');
754
- };
755
- const backfillSession = async (history) => {
756
- const state = db.prepare('SELECT cursor,complete FROM assistant_message_backfill WHERE assistant_id=? AND session_id=?').get(history.assistant_id, history.session_id);
757
- if (state?.complete) return true;
758
- const cursor = state?.cursor ?? null;
759
- let directory = history.directory ?? null;
760
- if (directory == null) {
761
- const session = await client().session.get({ sessionID: history.session_id }).catch(() => null);
762
- const resolved = resolveArchivedDirectory(history.assistant_id, session?.data?.directory, session?.data?.project?.worktree);
1019
+ return { error: { status: 500 }, status: 500 };
1020
+ };
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
+ );
763
1032
  if (resolved) {
764
- 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);
765
1035
  directory = resolved;
766
1036
  }
767
1037
  }
768
- const result = await fetchBackfillMessages(history, directory, cursor);
769
- if (isMissing(result)) return completeMissingSessionBackfill(history.assistant_id, history.session_id);
770
- if (result?.error) fail('upstream_error');
771
- const entries = Array.isArray(result?.data) ? result.data : Array.isArray(result?.data?.items) ? result.data.items : null;
772
- if (!entries) fail('upstream_error');
773
- db.exec('BEGIN IMMEDIATE');
774
- try {
775
- 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)); });
776
- const nextCursor = result?.response?.headers?.get('x-next-cursor') ?? null;
777
- const next = reduceBackfillState({ cursor, complete: false }, { type: 'page', nextCursor });
778
- // Successful pages only upsert authoritative rows; never delete provisional
779
- // event mirrors that the snapshot omitted — only 404 / message.removed /
780
- // assistant deletion may shrink the message set.
781
- writeBackfillState(history.assistant_id, history.session_id, next);
782
- db.exec('COMMIT');
783
- return next.complete;
784
- } catch (error) { db.exec('ROLLBACK'); throw error; }
1038
+ return directory;
785
1039
  };
786
- 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'); } };
787
- 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');
788
- const historicalMessages = async (assistantID, input = {}) => {
789
- 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);
790
- 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);
791
- 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));
792
- const beforeIncludes = (sessionOrdinal, messageOrdinal, messageID) => before == null || sessionOrdinal < before.sessionOrdinal || (sessionOrdinal === before.sessionOrdinal && (messageOrdinal < before.messageOrdinal || (messageOrdinal === before.messageOrdinal && messageID < before.messageID)));
793
- const pageRows = () => {
794
- // Archived mirrors include covered=1 authoritative rows and covered=0
795
- // provisional event mirrors so a partial REST snapshot cannot hide a reply
796
- // already observed on the live channel.
797
- 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);
798
- const current = row.current_session_id && !currentIsArchived
799
- ? 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))
800
- : [];
801
- 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);
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
+ };
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;
1144
+ }
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,
802
1156
  };
803
- 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);
804
- let rows = pageRows();
805
- // Demand-backfill incomplete archived sessions even when provisional
806
- // mirrors already fill the page, so authoritative upserts can elevate
807
- // covered=0 fallbacks. Cap remains BACKFILL_MAX_PAGES per request.
808
- for (let page = 0; page < BACKFILL_MAX_PAGES; page++) {
809
- const target = nextIncomplete();
810
- if (!target) break;
811
- try {
812
- await backfillSession(target);
813
- } catch (error) {
814
- // Partial-result scope is this request's pageRows() only: any already
815
- // visible covered or provisional row keeps the page usable and the
816
- // incomplete cursor retryable. An empty current page still throws.
817
- if (rows.length === 0) throw error;
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;
1179
+ };
1180
+ const historicalMessages = async (assistantID, input = {}) => {
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
+ };
1210
+ break;
1211
+ }
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;
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
+ };
818
1258
  break;
819
1259
  }
820
- rows = pageRows();
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
+ });
821
1276
  }
822
- 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;
823
- const 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)) }));
824
- 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
+ };
825
1292
  };
826
- 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(() => {});
827
1293
  const unsubscribeEvents = typeof globalEventHub?.subscribeEvent === 'function' ? globalEventHub.subscribeEvent(processEvent) : null;
828
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; } };
829
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)); };
830
- const compact = async (assistantID, input) => { const row = active(assistantID); if (!plainObject(input) || input.sessionID !== row.current_session_id || input.sessionGeneration !== row.session_generation || !row.current_session_id) fail('revision_conflict'); let target = row; let result = await client().session.summarize({ sessionID: target.current_session_id, directory: effectiveWorkspace(target), providerID: target.provider_id, modelID: target.model_id }); if (isMissing(result)) { await restoreOnce(row, row.current_session_id, row.session_generation); target = active(assistantID); result = await client().session.summarize({ sessionID: target.current_session_id, directory: effectiveWorkspace(target), providerID: target.provider_id, modelID: target.model_id }); } if (result.error || result.data !== true) fail('upstream_error'); return { binding: binding(active(assistantID)), summarized: true }; };
1296
+ const compact = async (assistantID, input) => { const row = active(assistantID); if (!plainObject(input) || input.sessionID !== row.current_session_id || input.sessionGeneration !== row.session_generation || !row.current_session_id) fail('revision_conflict'); let target = row; let result = await invokeSession(() => client().session.compact({ sessionID: target.current_session_id })); if (isMissing(result)) { await restoreOnce(row, row.current_session_id, row.session_generation); target = active(assistantID); result = await invokeSession(() => client().session.compact({ sessionID: target.current_session_id })); } if (result?.error || (result?.data !== true && result?.id == null && result?.type !== 'compaction')) fail('upstream_error'); return { binding: binding(active(assistantID)), summarized: true }; };
831
1297
  const sendWithConfig = async ({ row, sessionID, directory, config, parts, messageID, restore }) => {
832
- const sendPrompt = (targetSessionID, targetDirectory, targetConfig) => client().session.promptAsync({ sessionID: targetSessionID, directory: targetDirectory, ...targetConfig, parts, messageID });
833
- let result = await sendPrompt(sessionID, directory, config);
1298
+ const sendPrompt = async (targetSessionID, targetConfig) => invokeSession(async () => {
1299
+ const api = client();
1300
+ if (typeof api.session.switchAgent === 'function' && targetConfig?.agent) await api.session.switchAgent({ sessionID: targetSessionID, agent: targetConfig.agent });
1301
+ if (typeof api.session.switchModel === 'function' && targetConfig?.model) await api.session.switchModel({ sessionID: targetSessionID, model: { id: targetConfig.model.modelID, providerID: targetConfig.model.providerID, ...(targetConfig.variant ? { variant: targetConfig.variant } : {}) } });
1302
+ if (typeof api.session.instructions?.entry?.put === 'function' && typeof targetConfig?.system === 'string' && targetConfig.system) await api.session.instructions.entry.put({ sessionID: targetSessionID, key: 'system', value: targetConfig.system });
1303
+ return api.session.prompt(toV2PromptInput(targetSessionID, parts, messageID));
1304
+ });
1305
+ let result = await sendPrompt(sessionID, config);
834
1306
  if (isMissing(result) && restore) {
835
1307
  const restored = await restoreOnce(row, sessionID, row.session_generation);
836
1308
  const target = active(row.assistant_id);
837
1309
  const targetConfig = configuration(target);
838
- result = await sendPrompt(restored.sessionID, restored.directory, targetConfig);
839
- if (promptAdmitted(result)) mirrorAdmittedUserMessage(target, restored.sessionID, messageID, parts, targetConfig);
1310
+ result = await sendPrompt(restored.sessionID, targetConfig);
840
1311
  return { result, binding: restored };
841
1312
  }
842
- if (promptAdmitted(result)) mirrorAdmittedUserMessage(row, sessionID, messageID, parts, config);
843
1313
  return { result, binding: binding(row) };
844
1314
  };
845
1315
  const extractUserText = (input) => {
@@ -1012,12 +1482,16 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
1012
1482
  parts: [card],
1013
1483
  });
1014
1484
  if (card.cardType === 'session' && card.sessionID) {
1015
- upsertContactWatch(db, {
1485
+ const deliveryMessageID = pendingAssignMessageIDs.get(card.sessionID)
1486
+ || (nonEmptyString(card.messageID) ? card.messageID : null);
1487
+ if (pendingAssignMessageIDs.has(card.sessionID)) pendingAssignMessageIDs.delete(card.sessionID);
1488
+ upsertContactWatch(db, {
1016
1489
  assistantID,
1017
1490
  sessionID: card.sessionID,
1018
1491
  directory: card.directory,
1019
1492
  status: card.status === 'error' || card.status === 'question' || card.status === 'complete' ? card.status : 'busy',
1020
1493
  updatedAt: now(),
1494
+ ...(deliveryMessageID ? { messageID: deliveryMessageID } : {}),
1021
1495
  });
1022
1496
  }
1023
1497
  bump();
@@ -1115,12 +1589,16 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
1115
1589
  });
1116
1590
  ordinal += 1;
1117
1591
  if (card.cardType === 'session' && card.sessionID) {
1118
- upsertContactWatch(db, {
1592
+ const deliveryMessageID = pendingAssignMessageIDs.get(card.sessionID)
1593
+ || (nonEmptyString(card.messageID) ? card.messageID : null);
1594
+ if (pendingAssignMessageIDs.has(card.sessionID)) pendingAssignMessageIDs.delete(card.sessionID);
1595
+ upsertContactWatch(db, {
1119
1596
  assistantID,
1120
1597
  sessionID: card.sessionID,
1121
1598
  directory: card.directory,
1122
1599
  status: card.status === 'error' || card.status === 'question' || card.status === 'complete' ? card.status : 'busy',
1123
1600
  updatedAt: now(),
1601
+ ...(deliveryMessageID ? { messageID: deliveryMessageID } : {}),
1124
1602
  });
1125
1603
  }
1126
1604
  });
@@ -1259,16 +1737,19 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
1259
1737
  };
1260
1738
  const v2 = await viaExact(api?.v2?.session?.message, api?.v2?.session);
1261
1739
  if (v2) return v2;
1740
+ // Official 2.x: session.message.get; shim may also expose callable session.message.
1741
+ const nestedGet = await viaExact(api?.session?.message?.get, api?.session?.message);
1742
+ if (nestedGet) return nestedGet;
1262
1743
  const legacy = await viaExact(api?.session?.message, api?.session);
1263
1744
  if (legacy) return legacy;
1264
1745
  try {
1265
- const listed = await api.session.messages({
1746
+ const listed = await api.message.list({
1266
1747
  sessionID,
1267
- ...(directory ? { directory } : {}),
1268
1748
  limit: 100,
1749
+ order: 'desc',
1269
1750
  }, options);
1270
1751
  if (listed?.error) return { found: false };
1271
- const rows = Array.isArray(listed?.data) ? listed.data : (Array.isArray(listed) ? listed : []);
1752
+ const rows = projectionEntries(listed, sessionID) ?? (Array.isArray(listed?.data) ? listed.data : (Array.isArray(listed) ? listed : []));
1272
1753
  const hit = rows.some((row) => identity(row) === messageID);
1273
1754
  return { found: hit };
1274
1755
  } catch {
@@ -1295,14 +1776,14 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
1295
1776
  const fromSession = extractAssignSessionModel({ session: sessionPayload });
1296
1777
  if (fromSession) return fromSession;
1297
1778
  try {
1298
- if (typeof api.session.messages !== 'function') return null;
1299
- const listed = await api.session.messages({
1779
+ if (typeof api.message?.list !== 'function') return null;
1780
+ const listed = await api.message.list({
1300
1781
  sessionID,
1301
- ...(dir ? { directory: dir } : {}),
1302
1782
  limit: 40,
1783
+ order: 'desc',
1303
1784
  }, options);
1304
1785
  if (listed?.error) return null;
1305
- messages = listed?.data ?? listed;
1786
+ messages = projectionEntries(listed, sessionID) ?? listed?.data ?? listed;
1306
1787
  } catch {
1307
1788
  return null;
1308
1789
  }
@@ -1456,12 +1937,12 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
1456
1937
  }
1457
1938
 
1458
1939
  let messagesResult = null;
1459
- if (includeMessages && typeof client().session.messages === 'function') {
1940
+ if (includeMessages && typeof client().message?.list === 'function') {
1460
1941
  try {
1461
- messagesResult = await client().session.messages({
1942
+ messagesResult = await client().message.list({
1462
1943
  sessionID,
1463
- directory,
1464
1944
  limit: 100,
1945
+ order: 'desc',
1465
1946
  }, options);
1466
1947
  if (messagesResult?.error && !isMissing(messagesResult)) {
1467
1948
  // Message fetch failure must not invent status; keep messages null.
@@ -1499,14 +1980,17 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
1499
1980
  const { signal } = params;
1500
1981
  signal?.throwIfAborted();
1501
1982
  const resolved = await resolveReferencedSession({ sessionID: params.sessionID, signal, includeMessages: false });
1502
- const result = await client().session.messages({
1503
- sessionID: resolved.sessionID, directory: resolved.directory, limit,
1504
- ...(params.before ? { before: params.before } : {}),
1505
- }, signal ? { signal } : undefined);
1983
+ // First page: order=desc. Continuation: opaque cursor only (upstream 400s on both).
1984
+ const result = await invokeSession(() => client().message.list({
1985
+ sessionID: resolved.sessionID, limit,
1986
+ ...(params.before ? { cursor: params.before } : { order: 'desc' }),
1987
+ }));
1506
1988
  signal?.throwIfAborted();
1507
1989
  if (isMissing(result)) throw new AssignError(ASSIGN_CODES.NOT_FOUND, 'That session no longer exists.');
1508
- if (result?.error || !Array.isArray(result?.data)) throw new AssignError(ASSIGN_CODES.UPSTREAM, 'Could not read session messages.');
1509
- 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();
1510
1994
  if (rows.some((entry) => (entry?.info?.sessionID && entry.info.sessionID !== resolved.sessionID))) throw new AssignError(ASSIGN_CODES.UPSTREAM, 'Session message identity mismatch.');
1511
1995
  let remaining = 24000;
1512
1996
  let partial = rows.length > limit;
@@ -1532,8 +2016,7 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
1532
2016
  }
1533
2017
  return { messageID: info.id, role: info.role, parts };
1534
2018
  });
1535
- const cursor = result.response?.headers?.get('x-next-cursor');
1536
- const nextCursor = typeof cursor === 'string' && cursor ? cursor : null;
2019
+ const nextCursor = projectionCursor(result);
1537
2020
  return { sessionID: resolved.sessionID, directory: resolved.directory, title: resolved.title.slice(0, 500), messages, nextCursor, partial: partial || nextCursor !== null };
1538
2021
  };
1539
2022
  const assignWork = async (row, params = {}) => {
@@ -1614,7 +2097,7 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
1614
2097
  }
1615
2098
  // Worker model is prompt-only. Never mutate the contact assistant row.
1616
2099
  // Forward tool variant + AbortSignal; lookupMessage uses real SDK exact/list paths.
1617
- return assignSession({
2100
+ const assigned = await assignSession({
1618
2101
  ...params,
1619
2102
  ...(reuseScope ? {
1620
2103
  sessionID: reuseScope.sessionID,
@@ -1641,16 +2124,43 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
1641
2124
  allowedRoots: getAllowedRoots(),
1642
2125
  managedWorkspaceRoot: path.resolve(dataDir, 'assistant-workspaces'),
1643
2126
  listWorktrees,
1644
- createSession: (created) => client().session.create(created, sdkOptions),
1645
- promptExisting: (prompted) => client().session.promptAsync(prompted, sdkOptions),
2127
+ createSession: (created) => client().session.create({
2128
+ title: created?.title,
2129
+ ...(created?.directory ? { location: { directory: created.directory } } : {}),
2130
+ ...(created?.agent ? { agent: created.agent } : {}),
2131
+ ...(created?.model ? { model: created.model } : {}),
2132
+ ...(created?.metadata ? { metadata: created.metadata } : {}),
2133
+ }),
2134
+ promptExisting: async (prompted) => {
2135
+ const api = client();
2136
+ if (typeof api.session.switchAgent === 'function' && prompted.agent) {
2137
+ await api.session.switchAgent({ sessionID: prompted.sessionID, agent: prompted.agent });
2138
+ }
2139
+ if (typeof api.session.switchModel === 'function' && prompted.model) {
2140
+ await api.session.switchModel({
2141
+ sessionID: prompted.sessionID,
2142
+ model: {
2143
+ id: prompted.model.modelID,
2144
+ providerID: prompted.model.providerID,
2145
+ ...(prompted.variant ? { variant: prompted.variant } : {}),
2146
+ },
2147
+ });
2148
+ }
2149
+ return api.session.prompt(toV2PromptInput(prompted.sessionID, prompted.parts, prompted.messageID));
2150
+ },
1646
2151
  deleteSession: (deleted) => (
1647
- typeof client().session.delete === 'function'
1648
- ? client().session.delete(deleted, sdkOptions)
2152
+ typeof client().session.remove === 'function'
2153
+ ? client().session.remove({ sessionID: deleted?.sessionID, ...(deleted?.directory ? {} : {}) })
1649
2154
  : undefined
1650
2155
  ),
1651
2156
  lookupMessage: (lookup) => lookupAssignMessage({ ...lookup, signal }),
1652
2157
  messageID: params?.messageID || `msg_assign_${id()}`,
1653
2158
  });
2159
+ // Stash until the session card watch is written so reuse cannot settle on an old tail.
2160
+ if (nonEmptyString(assigned?.sessionID) && nonEmptyString(assigned?.messageID)) {
2161
+ pendingAssignMessageIDs.set(assigned.sessionID, assigned.messageID);
2162
+ }
2163
+ return assigned;
1654
2164
  };
1655
2165
  /** Watch an existing session only — no prompt. Baseline status avoids false settle resume. */
1656
2166
  const watchSessionWork = async (_row, params = {}) => {
@@ -1675,7 +2185,7 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
1675
2185
  reused: true,
1676
2186
  };
1677
2187
  };
1678
- /** Abort an existing coding session via real OpenCode session.abort. */
2188
+ /** Abort an existing coding session via real OpenCode session.interrupt. */
1679
2189
  const stopSessionWork = async (_row, params = {}) => {
1680
2190
  const sessionID = typeof params.sessionID === 'string' ? params.sessionID.trim() : '';
1681
2191
  if (!sessionID) {
@@ -1690,8 +2200,8 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
1690
2200
  });
1691
2201
  const signal = params.signal;
1692
2202
  signal?.throwIfAborted();
1693
- if (typeof client().session.abort !== 'function') {
1694
- throw new AssignError(ASSIGN_CODES.UPSTREAM, 'Session abort is unavailable.');
2203
+ if (typeof client().session.interrupt !== 'function') {
2204
+ throw new AssignError(ASSIGN_CODES.UPSTREAM, 'Session interrupt is unavailable.');
1695
2205
  }
1696
2206
  const targetID = resolved.sessionID;
1697
2207
  // Share one abort lifecycle per worker session (register before await abort).
@@ -1719,10 +2229,9 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
1719
2229
  let result;
1720
2230
  try {
1721
2231
  signal?.throwIfAborted();
1722
- result = await client().session.abort({
2232
+ result = await invokeSession(() => client().session.interrupt({
1723
2233
  sessionID: targetID,
1724
- directory: resolved.directory,
1725
- }, sdkOptions);
2234
+ }));
1726
2235
  } catch (error) {
1727
2236
  const observed = entry.observed;
1728
2237
  if (sessionAbortInflight.get(targetID) === entry) sessionAbortInflight.delete(targetID);
@@ -1784,27 +2293,69 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
1784
2293
  }
1785
2294
  const resolved = await resolveReferencedSession({ sessionID, signal: params.signal, includeMessages: false });
1786
2295
  params.signal?.throwIfAborted();
1787
- const session = client().session;
1788
- const method = operation === 'steer' ? 'promptAsync' : operation === 'archive' ? 'update' : 'delete';
1789
- if (typeof session[method] !== 'function') throw new AssignError(ASSIGN_CODES.UPSTREAM, `Session ${operation} is unavailable.`);
1790
- const request = { sessionID, directory: resolved.directory };
1791
- if (operation === 'steer') Object.assign(request, {
1792
- messageID: `msg_steer_${id()}`, delivery: 'steer', parts: [{ type: 'text', text: params.text.trim() }],
1793
- });
1794
- if (operation === 'archive') request.time = { archived: now() };
2296
+ const api = client();
1795
2297
  let result;
1796
2298
  try {
1797
- result = await session[method](request, params.signal ? { signal: params.signal } : undefined);
2299
+ if (operation === 'steer') {
2300
+ const messageID = `msg_steer_${id()}`;
2301
+ const parts = [{ type: 'text', text: params.text.trim() }];
2302
+ result = await invokeSession(() => api.session.prompt(toV2PromptInput(sessionID, parts, messageID)));
2303
+ if (isMissing(result)) throw new AssignError(ASSIGN_CODES.NOT_FOUND, `No coding session ${sessionID} was found.`);
2304
+ if (result?.error || !promptAdmitted(result)) {
2305
+ throw new AssignError(ASSIGN_CODES.UPSTREAM, `Session ${operation} was not confirmed. Do not resend automatically.`);
2306
+ }
2307
+ return { sessionID, directory: resolved.directory, operation, messageID, admitted: true };
2308
+ }
2309
+ if (operation === 'archive') {
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') {
2313
+ throw new AssignError(ASSIGN_CODES.UPSTREAM, 'Session archive is unavailable.');
2314
+ }
2315
+ result = await archiveSessionHost({
2316
+ sessionID,
2317
+ directory: resolved.directory,
2318
+ archivedAt: now(),
2319
+ });
2320
+ } else if (operation === 'delete') {
2321
+ if (typeof api.session.remove !== 'function') {
2322
+ throw new AssignError(ASSIGN_CODES.UPSTREAM, 'Session delete is unavailable.');
2323
+ }
2324
+ result = await invokeSession(() => api.session.remove({ sessionID }));
2325
+ } else {
2326
+ throw new AssignError(ASSIGN_CODES.VALIDATION, `Unknown session operation ${operation}.`);
2327
+ }
1798
2328
  } catch (error) {
2329
+ if (error instanceof AssignError) throw error;
1799
2330
  throw new AssignError(ASSIGN_CODES.UPSTREAM, error?.message || `Session ${operation} failed.`);
1800
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
+ }
1801
2339
  if (isMissing(result)) throw new AssignError(ASSIGN_CODES.NOT_FOUND, `No coding session ${sessionID} was found.`);
1802
- if (result?.error || result?.data === false || (operation === 'steer' && !promptAdmitted(result))) {
1803
- // Do not retry an ambiguous prompt submission: it may already be admitted upstream.
2340
+ if (result?.error || result?.data === false) {
1804
2341
  throw new AssignError(ASSIGN_CODES.UPSTREAM, `Session ${operation} was not confirmed. Do not resend automatically.`);
1805
2342
  }
1806
- if (operation !== 'steer') reportAssignedSession(sessionID, 'cancelled');
1807
- return { sessionID, directory: resolved.directory, operation, ...(operation === 'steer' ? { messageID: request.messageID, admitted: true } : { [operation === 'archive' ? 'archived' : 'deleted']: true }) };
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
+ }
2357
+ reportAssignedSession(sessionID, 'cancelled', false);
2358
+ return { sessionID, directory: resolved.directory, operation, deleted: true };
1808
2359
  };
1809
2360
  const matchRegisteredProject = async (directory) => {
1810
2361
  const projects = await listProjects();
@@ -2192,11 +2743,11 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
2192
2743
  try { return effectiveWorkspace(live); } catch { return null; }
2193
2744
  })();
2194
2745
  if (resolvedDirectory) {
2195
- const messagesResult = await client().session.messages({
2746
+ const messagesResult = await invokeSession(() => client().message.list({
2196
2747
  sessionID,
2197
- directory: resolvedDirectory,
2198
2748
  limit: 100,
2199
- });
2749
+ order: 'desc',
2750
+ }));
2200
2751
  if (!messagesResult?.error) {
2201
2752
  const rows = Array.isArray(messagesResult?.data)
2202
2753
  ? messagesResult.data
@@ -2959,12 +3510,16 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
2959
3510
  parts: [card],
2960
3511
  });
2961
3512
  if (card.cardType === 'session' && card.sessionID) {
3513
+ const deliveryMessageID = pendingAssignMessageIDs.get(card.sessionID)
3514
+ || (nonEmptyString(card.messageID) ? card.messageID : null);
3515
+ if (pendingAssignMessageIDs.has(card.sessionID)) pendingAssignMessageIDs.delete(card.sessionID);
2962
3516
  upsertContactWatch(db, {
2963
3517
  assistantID: row.assistant_id,
2964
3518
  sessionID: card.sessionID,
2965
3519
  directory: card.directory,
2966
3520
  status: card.status === 'error' || card.status === 'question' || card.status === 'complete' ? card.status : 'busy',
2967
3521
  updatedAt: now(),
3522
+ ...(deliveryMessageID ? { messageID: deliveryMessageID } : {}),
2968
3523
  });
2969
3524
  }
2970
3525
  bump();
@@ -2977,7 +3532,7 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
2977
3532
  };
2978
3533
  /**
2979
3534
  * Read-only inter-assistant DM. Inserts into the recipient's OpenChamber
2980
- * contact transcript only. Must never call OpenCode promptAsync, mutate
3535
+ * contact transcript only. Must never call OpenCode session.prompt, mutate
2981
3536
  * sessions/files/worktrees, or run tools on the recipient's behalf.
2982
3537
  *
2983
3538
  * Assign opens a worker session on the sender's contact turn — never here.
@@ -3068,17 +3623,17 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
3068
3623
  db.prepare('UPDATE assistant_contact_watch SET resume_allowed=0 WHERE assistant_id=?').run(assistantID);
3069
3624
  if (interrupted) return { binding: binding(row), aborted: true };
3070
3625
  if (!row.current_session_id) fail('revision_conflict');
3071
- const result = await client().session.abort({ sessionID: row.current_session_id, directory: effectiveWorkspace(row) });
3626
+ const result = await invokeSession(() => client().session.interrupt({ sessionID: row.current_session_id }));
3072
3627
  if (isMissing(result)) fail('not_found');
3073
- if (result.error) fail('upstream_error');
3628
+ if (result?.error) fail('upstream_error');
3074
3629
  return { binding: binding(row), aborted: true };
3075
3630
  };
3076
3631
  const createNew = async (assistantID) => { for (let attempt = 0; attempt < 3; attempt++) { const row = active(assistantID); const created = await createSession(row); const won = replaceBinding(row, created); if (won) return binding(won); } return binding(active(assistantID)); };
3077
3632
  const shareOperation = (operationID) => { const row = db.prepare('SELECT * FROM assistant_share_operation WHERE operation_id=?').get(operationID); return row && { operationID: row.operation_id, assistantID: row.assistant_id, sessionID: row.session_id, messageID: row.message_id, state: row.state, phase: row.phase, attempt: row.attempt, leaseExpiresAt: row.lease_expires_at, errorCode: row.error_code }; };
3078
3633
  const claim = (operationID, retry = false) => { db.exec('BEGIN IMMEDIATE'); try { const operation = db.prepare('SELECT * FROM assistant_share_operation WHERE operation_id=?').get(operationID); if (!operation) { db.exec('COMMIT'); return null; } const at = now(); const eligible = operation.state === 'failed' ? retry : operation.state === 'running' && operation.lease_expires_at <= at && operation.phase === 'admitted'; if (!eligible || operation.attempt >= SHARE_MAX_ATTEMPTS) { db.exec('COMMIT'); return null; } const result = db.prepare("UPDATE assistant_share_operation SET state='running',phase='submitting',attempt=attempt+1,lease_expires_at=?,error_code=NULL,updated_at=? WHERE operation_id=? AND state=? AND phase=? AND attempt<? AND (state='failed' OR lease_expires_at<=?)").run(at + SHARE_LEASE_MS, at, operationID, operation.state, operation.phase, SHARE_MAX_ATTEMPTS, at); const claimed = result.changes ? db.prepare('SELECT * FROM assistant_share_operation WHERE operation_id=?').get(operationID) : null; db.exec('COMMIT'); return claimed; } catch (error) { db.exec('ROLLBACK'); throw error; } };
3079
3634
  const completeOrFail = (operation, errorCode = null) => { const at = now(); if (errorCode) db.prepare("UPDATE assistant_share_operation SET state='failed',phase='admitted',error_code=?,lease_expires_at=NULL,updated_at=? WHERE operation_id=? AND state='running' AND phase='submitting'").run(errorCode, at, operation.operation_id); else db.prepare("UPDATE assistant_share_operation SET state='running',phase='submitted',error_code=NULL,lease_expires_at=?,updated_at=? WHERE operation_id=? AND state='running' AND phase='submitting'").run(at + SHARE_LEASE_MS, at, operation.operation_id); };
3080
- const submitClaim = async (operation) => { const payload = parse(operation.response); const row = active(operation.assistant_id); try { const config = configuration(row); const result = await client().session.promptAsync({ sessionID: operation.session_id, directory: effectiveWorkspace(row), ...config, parts: payload.parts, messageID: operation.message_id }); if (!promptAdmitted(result)) fail('upstream_error'); mirrorAdmittedUserMessage(row, operation.session_id, operation.message_id, payload.parts, config); completeOrFail(operation); } catch (error) { completeOrFail(operation, error instanceof AssistantError ? error.code : 'upstream_error'); } };
3081
- const reconcileShareOperations = async () => { const candidates = db.prepare("SELECT * FROM assistant_share_operation WHERE state='running'").all(); for (const operation of candidates) { const row = assistant(operation.assistant_id); if (!row || !operation.session_id || !operation.message_id) continue; try { const result = await client().session.messages({ sessionID: operation.session_id, directory: effectiveWorkspace(row), limit: 100 }); const messages = result.data ?? []; const found = Array.isArray(messages) && messages.some((message) => message?.info?.id === operation.message_id || message?.id === operation.message_id); if (found) db.prepare("UPDATE assistant_share_operation SET state='completed',phase='submitted',lease_expires_at=NULL,updated_at=? WHERE operation_id=? AND state='running'").run(now(), operation.operation_id); else if (operation.phase === 'admitted' && operation.lease_expires_at <= now() && operation.attempt >= SHARE_MAX_ATTEMPTS) db.prepare("UPDATE assistant_share_operation SET state='failed',lease_expires_at=NULL,error_code='attempt_limit',updated_at=? WHERE operation_id=? AND state='running' AND phase='admitted'").run(now(), operation.operation_id); else if (operation.phase === 'admitted' && operation.lease_expires_at <= now()) { const claimed = claim(operation.operation_id); if (claimed) void submitClaim(claimed); } else if (operation.phase === 'submitted' && operation.lease_expires_at <= now()) db.prepare("UPDATE assistant_share_operation SET state='unresolved',lease_expires_at=NULL,error_code='message_unresolved',updated_at=? WHERE operation_id=? AND state='running' AND phase='submitted'").run(now(), operation.operation_id); } catch { /* Reconciliation remains retryable until its lease expires. */ } } };
3635
+ const submitClaim = async (operation) => { const payload = parse(operation.response); const row = active(operation.assistant_id); try { const config = configuration(row); const sent = await sendWithConfig({ row, sessionID: operation.session_id, directory: effectiveWorkspace(row), config, parts: payload.parts, messageID: operation.message_id, restore: false }); if (!promptAdmitted(sent.result)) fail('upstream_error'); completeOrFail(operation); } catch (error) { completeOrFail(operation, error instanceof AssistantError ? error.code : 'upstream_error'); } };
3636
+ const reconcileShareOperations = async () => { const candidates = db.prepare("SELECT * FROM assistant_share_operation WHERE state='running'").all(); for (const operation of candidates) { const row = assistant(operation.assistant_id); if (!row || !operation.session_id || !operation.message_id) continue; try { const result = await invokeSession(() => client().message.list({ sessionID: operation.session_id, limit: 100, order: 'desc' })); if (result?.error) continue; const messages = projectionEntries(result, operation.session_id) ?? result.data ?? []; const found = Array.isArray(messages) && messages.some((message) => message?.info?.id === operation.message_id || message?.id === operation.message_id); if (found) db.prepare("UPDATE assistant_share_operation SET state='completed',phase='submitted',lease_expires_at=NULL,updated_at=? WHERE operation_id=? AND state='running'").run(now(), operation.operation_id); else if (operation.phase === 'admitted' && operation.lease_expires_at <= now() && operation.attempt >= SHARE_MAX_ATTEMPTS) db.prepare("UPDATE assistant_share_operation SET state='failed',lease_expires_at=NULL,error_code='attempt_limit',updated_at=? WHERE operation_id=? AND state='running' AND phase='admitted'").run(now(), operation.operation_id); else if (operation.phase === 'admitted' && operation.lease_expires_at <= now()) { const claimed = claim(operation.operation_id); if (claimed) void submitClaim(claimed); } else if (operation.phase === 'submitted' && operation.lease_expires_at <= now()) db.prepare("UPDATE assistant_share_operation SET state='unresolved',lease_expires_at=NULL,error_code='message_unresolved',updated_at=? WHERE operation_id=? AND state='running' AND phase='submitted'").run(now(), operation.operation_id); } catch { /* Reconciliation remains retryable until its lease expires. */ } } };
3082
3637
  const reconcile = async () => {
3083
3638
  if (closed) return;
3084
3639
  try {
@@ -3154,7 +3709,7 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
3154
3709
  )),
3155
3710
  };
3156
3711
  };
3157
- return { capability: async () => ({ supported: true, enabled: enabled(), revision: revision(), serverInstanceID: await getServerId() }), 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) => {
3158
3713
  db.exec('BEGIN IMMEDIATE');
3159
3714
  try {
3160
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);
@@ -3178,6 +3733,7 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
3178
3733
  }, ensure, createNew, compact, send, whenContactTurnSettled, abort, captureQueueDeliveryTarget, sendWithCapturedConfig, share, shareOperation, historicalMessages, contactMessages, clearContactMemory, resetContact, markContactRead, appendContactCard, deliverPeerMessage, listAssistantScheduledTasks, putAssistantContactAttachment, getAssistantContactAttachment, migrateContactAttachments, processEvent, reportAssignedSessionSettle: reportAssignedSession, reconcile, close: () => {
3179
3734
  cancelAssignedResumes({});
3180
3735
  sessionAbortInflight.clear();
3736
+ pendingAssignMessageIDs.clear();
3181
3737
  for (const turn of contactControllers.values()) turn.controller.abort();
3182
3738
  if (!closed) {
3183
3739
  closed = true;