@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
@@ -24,24 +24,24 @@ const response = () => ({
24
24
  });
25
25
 
26
26
  describe('session index routes', () => {
27
- it('returns deterministic unsupported state outside Electron', () => {
27
+ it('returns deterministic unsupported state outside Electron', async () => {
28
28
  const { app, route } = registry();
29
29
  registerSessionIndexRoutes(app, { sessionIndexService: null });
30
30
  const res = response();
31
31
 
32
- route('GET', '/api/openchamber/session-index')({}, res);
32
+ await route('GET', '/api/openchamber/session-index')({}, res);
33
33
 
34
34
  expect(res.statusCode).toBe(501);
35
35
  expect(res.body).toMatchObject({ error: expect.stringContaining('unavailable') });
36
36
  });
37
37
 
38
- it('returns an Electron snapshot through the OpenChamber route', () => {
38
+ it('returns an Electron snapshot through the OpenChamber route', async () => {
39
39
  const { app, route } = registry();
40
40
  const sessionIndexService = { snapshot: () => ({ directories: [{ directory: '/repo', sessions: [] }] }) };
41
41
  registerSessionIndexRoutes(app, { sessionIndexService });
42
42
  const res = response();
43
43
 
44
- route('GET', '/api/openchamber/session-index')({}, res);
44
+ await route('GET', '/api/openchamber/session-index')({}, res);
45
45
 
46
46
  expect(res.statusCode).toBe(200);
47
47
  expect(res.body).toEqual({ available: true, directories: [{ directory: '/repo', sessions: [] }] });
@@ -91,7 +91,7 @@ describe('session index routes', () => {
91
91
  expect(route('GET', '/api/openchamber/session-index/changes')).toBeUndefined();
92
92
  });
93
93
 
94
- it('looks up a session by id for deep links', () => {
94
+ it('looks up a session by id for deep links', async () => {
95
95
  const { app, route } = registry();
96
96
  const findBySessionId = vi.fn(() => ({
97
97
  id: 'ses_abc',
@@ -106,7 +106,7 @@ describe('session index routes', () => {
106
106
  });
107
107
  const res = response();
108
108
 
109
- route('GET', '/api/openchamber/session-index/session/:sessionId')(
109
+ await route('GET', '/api/openchamber/session-index/session/:sessionId')(
110
110
  { params: { sessionId: 'ses_abc' } },
111
111
  res,
112
112
  );
@@ -119,7 +119,7 @@ describe('session index routes', () => {
119
119
  });
120
120
  });
121
121
 
122
- it('returns 404 when session id is unknown', () => {
122
+ it('returns 404 when session id is unknown', async () => {
123
123
  const { app, route } = registry();
124
124
  registerSessionIndexRoutes(app, {
125
125
  sessionIndexService: {
@@ -129,7 +129,7 @@ describe('session index routes', () => {
129
129
  });
130
130
  const res = response();
131
131
 
132
- route('GET', '/api/openchamber/session-index/session/:sessionId')(
132
+ await route('GET', '/api/openchamber/session-index/session/:sessionId')(
133
133
  { params: { sessionId: 'ses_missing' } },
134
134
  res,
135
135
  );
@@ -284,8 +284,14 @@ export const createSessionIndexService = ({ dbPath, getRuntimeConfig = () => nul
284
284
  const previousStateByID = new Map(
285
285
  existingSummaryState.all(key, normalizedDirectory).map((row) => [row.session_id, row]),
286
286
  );
287
+ // Active-only: archived sessions never enter the cold-start index. Host
288
+ // archive projection must set time.archived before replaceDirectory so a
289
+ // stale unarchived cache cannot resurrect them.
287
290
  const summaries = Array.isArray(sessions)
288
- ? sessions.map((session) => toSummary(session, normalizedDirectory)).filter(Boolean).slice(0, MAX_ROOT_SESSIONS)
291
+ ? sessions
292
+ .map((session) => toSummary(session, normalizedDirectory))
293
+ .filter((summary) => summary && !summary.archivedAt)
294
+ .slice(0, MAX_ROOT_SESSIONS)
289
295
  : [];
290
296
  touchDirectory.run({
291
297
  runtimeKey: key,
@@ -1,4 +1,6 @@
1
1
  const SESSION_LIMIT = 20;
2
+ /** Max upstream list pages while skipping consecutive Host-archived rows. */
3
+ const ARCHIVE_SKIP_PAGE_BUDGET = 5;
2
4
  const FULL_RECONCILE_INTERVAL_MS = 24 * 60 * 60 * 1000;
3
5
  const FETCH_TIMEOUT_MS = 6_000;
4
6
  const INTERACTIVE_YIELD_MS = 1_000;
@@ -15,12 +17,55 @@ const updatedAt = (session) => {
15
17
  return typeof value === 'number' && Number.isFinite(value) ? value : 0;
16
18
  };
17
19
 
20
+ const isArchivedSession = (session) => {
21
+ const archived = session?.time?.archived;
22
+ return typeof archived === 'number' && Number.isFinite(archived) && archived > 0;
23
+ };
24
+
25
+ const nonEmptySystemID = (value) => typeof value === 'string' && value.length > 0;
26
+
27
+ /** Match session-index service: system sessions never consume the active-20 budget. */
28
+ const isSystemSession = (session) => {
29
+ const openchamber = session?.metadata?.openchamber;
30
+ if (!openchamber || typeof openchamber !== 'object') return false;
31
+ if (openchamber.assigned?.from === 'contact') return false;
32
+ if (nonEmptySystemID(openchamber.assistant?.assistantID)) return true;
33
+ if (nonEmptySystemID(openchamber.scheduledTask?.taskID)) return true;
34
+ if (nonEmptySystemID(openchamber.smallModel?.purpose)) return true;
35
+ if (nonEmptySystemID(openchamber.llm?.purpose)) return true;
36
+ return false;
37
+ };
38
+
39
+ const isRootActiveCandidate = (session) => {
40
+ if (!session?.id || isArchivedSession(session)) return false;
41
+ if (typeof session.parentID === 'string' && session.parentID) return false;
42
+ if (session.title === 'smartfetch-secondary') return false;
43
+ if (isSystemSession(session)) return false;
44
+ return true;
45
+ };
46
+
47
+ /**
48
+ * v2 list cursor may be a string/number or `{ next }`. Never String(object).
49
+ * @returns {string | null}
50
+ */
51
+ export const extractSessionListCursorToken = (cursor) => {
52
+ if (cursor == null || cursor === '') return null;
53
+ if (typeof cursor === 'string' || typeof cursor === 'number' || typeof cursor === 'bigint') {
54
+ const token = String(cursor).trim();
55
+ return token.length > 0 ? token : null;
56
+ }
57
+ if (typeof cursor === 'object' && cursor.next != null && cursor.next !== '') {
58
+ return extractSessionListCursorToken(cursor.next);
59
+ }
60
+ return null;
61
+ };
62
+
18
63
  const mergeIncrementalSessions = (cached, changed) => {
19
64
  const byID = new Map();
20
65
  for (const session of cached ?? []) byID.set(session.id, session);
21
66
  for (const session of changed ?? []) {
22
67
  if (!session?.id) continue;
23
- if (session.time?.archived) byID.delete(session.id);
68
+ if (isArchivedSession(session)) byID.delete(session.id);
24
69
  else byID.set(session.id, session);
25
70
  }
26
71
  return [...byID.values()]
@@ -35,6 +80,12 @@ export const createSessionIndexSyncRuntime = ({
35
80
  waitForOpenCodeReady,
36
81
  /** Optional tip sink: ({ revision, sync }) after each revision bump. */
37
82
  onRevisionTip = null,
83
+ /**
84
+ * Optional Host authority projection applied to each upstream list page
85
+ * before active-only filtering (archive / metadata). Sync — no await.
86
+ * @type {((sessions: object[]) => object[]) | null}
87
+ */
88
+ projectSessions = null,
38
89
  fetchFn = globalThis.fetch,
39
90
  now = Date.now,
40
91
  setTimer = setTimeout,
@@ -116,53 +167,125 @@ export const createSessionIndexSyncRuntime = ({
116
167
  .find((entry) => entry.directory === directory);
117
168
 
118
169
  const fetchDirectory = async (task) => {
119
- const cached = readDirectory(task.directory);
120
- // Empty snapshots are not incrementally eligible. A successful [] still
121
- // stores lastSyncedAt as a worktree topology hint; using that watermark as
122
- // start would hide older historical roots until the 24h full reconcile.
123
- const useIncremental = Boolean(
124
- (cached?.sessions?.length ?? 0) > 0
125
- && cached?.lastSyncedAt > 0
126
- && now() - cached.lastFullSyncedAt < FULL_RECONCILE_INTERVAL_MS
127
- );
128
- // Keep parity with @opencode-ai/sdk/v2 experimental.session.list. The
129
- // regular /session endpoint has different pagination semantics.
130
- const url = new URL(buildOpenCodeUrl('/experimental/session'));
131
- url.searchParams.set('directory', task.directory);
132
- url.searchParams.set('roots', 'true');
133
- url.searchParams.set('limit', String(SESSION_LIMIT));
134
- if (useIncremental) url.searchParams.set('start', String(cached.lastSyncedAt));
135
-
170
+ // v2 `session.list` cursor pagination. Collect candidates across pages,
171
+ // then re-project once before commit so a Host archive that lands mid-sync
172
+ // cannot resurrect a row. Root/system filters run before the 20-slot budget.
173
+ // projectSessions failure aborts the refresh and keeps prior directory rows.
136
174
  const controller = new AbortController();
137
175
  currentController = controller;
138
176
  currentWasPreempted = false;
139
177
  const timeout = setTimer(() => controller.abort(), FETCH_TIMEOUT_MS);
140
178
  try {
141
- const response = await fetchFn(url, {
142
- method: 'GET',
143
- headers: { Accept: 'application/json', ...getOpenCodeAuthHeaders() },
144
- signal: controller.signal,
145
- });
146
- if (!response.ok) {
147
- const error = new Error(`OpenCode session list failed (${response.status})`);
148
- error.status = response.status;
149
- throw error;
179
+ /** @type {object[]} raw upstream sessions collected across pages */
180
+ const collected = [];
181
+ let pages = 0;
182
+ let cursorToken = null;
183
+ let upstreamHasMore = false;
184
+ let hitPageBudget = false;
185
+ let lastCursorToken = null;
186
+ let seenCursor = new Set();
187
+
188
+ while (collected.length < SESSION_LIMIT * 3 && pages < ARCHIVE_SKIP_PAGE_BUDGET) {
189
+ const url = new URL(buildOpenCodeUrl('/session'));
190
+ url.searchParams.set('directory', task.directory);
191
+ url.searchParams.set('limit', String(SESSION_LIMIT));
192
+ if (cursorToken) {
193
+ url.searchParams.set('cursor', cursorToken);
194
+ }
195
+
196
+ const response = await fetchFn(url, {
197
+ method: 'GET',
198
+ headers: { Accept: 'application/json', ...getOpenCodeAuthHeaders() },
199
+ signal: controller.signal,
200
+ });
201
+ if (!response.ok) {
202
+ const error = new Error(`OpenCode session list failed (${response.status})`);
203
+ error.status = response.status;
204
+ throw error;
205
+ }
206
+ const payload = await response.json();
207
+ const sessions = Array.isArray(payload) ? payload : payload?.data;
208
+ if (!Array.isArray(sessions)) throw new Error('Invalid OpenCode session list payload');
209
+ pages += 1;
210
+
211
+ for (const session of sessions) {
212
+ if (session?.id) collected.push(session);
213
+ }
214
+
215
+ // v2: exhaust by cursor.next when present. A short page with next is
216
+ // still more data (archived skips / filtered upstream). Bare arrays
217
+ // have no cursor — length < limit means end of list.
218
+ const isEnvelope = payload && typeof payload === 'object' && !Array.isArray(payload);
219
+ const nextToken = isEnvelope
220
+ ? extractSessionListCursorToken(payload.cursor)
221
+ : null;
222
+ const pageFull = sessions.length === SESSION_LIMIT;
223
+ if (nextToken) lastCursorToken = nextToken;
224
+
225
+ if (nextToken) {
226
+ upstreamHasMore = true;
227
+ if (seenCursor.has(nextToken)) {
228
+ // Duplicate cursor → stop (broken upstream) rather than loop forever.
229
+ hitPageBudget = true;
230
+ break;
231
+ }
232
+ seenCursor.add(nextToken);
233
+ if (pages >= ARCHIVE_SKIP_PAGE_BUDGET) {
234
+ hitPageBudget = true;
235
+ break;
236
+ }
237
+ cursorToken = nextToken;
238
+ continue;
239
+ }
240
+
241
+ // No next token: bare-array / terminal page.
242
+ if (!pageFull) {
243
+ upstreamHasMore = false;
244
+ break;
245
+ }
246
+ // Full page without cursor — cannot continue safely.
247
+ upstreamHasMore = true;
248
+ hitPageBudget = true;
249
+ break;
150
250
  }
151
- const sessions = await response.json();
152
- if (!Array.isArray(sessions)) throw new Error('Invalid OpenCode session list payload');
153
- const nextSessions = useIncremental
154
- ? mergeIncrementalSessions(cached?.sessions, sessions)
155
- : sessions.slice(0, SESSION_LIMIT);
156
- const oldest = nextSessions[nextSessions.length - 1];
251
+
252
+ // Re-project the full collected set at commit time (current Host authority).
253
+ let projected = collected;
254
+ if (typeof projectSessions === 'function') {
255
+ try {
256
+ const next = projectSessions(collected);
257
+ if (!Array.isArray(next)) {
258
+ throw new Error('projectSessions must return an array');
259
+ }
260
+ projected = next;
261
+ } catch (error) {
262
+ // Failure is not empty success — keep prior directory rows.
263
+ console.warn('[session-index] projectSessions failed; keeping prior directory:', error?.message ?? error);
264
+ throw error;
265
+ }
266
+ }
267
+
268
+ // Root + system filters before filling the active-20 capacity.
269
+ const active = [];
270
+ for (const session of projected) {
271
+ if (!isRootActiveCandidate(session)) continue;
272
+ if (active.length >= SESSION_LIMIT) break;
273
+ active.push(session);
274
+ }
275
+
276
+ const oldest = active[active.length - 1];
277
+ const cursorNumber = lastCursorToken && Number.isFinite(Number(lastCursorToken))
278
+ ? Number(lastCursorToken)
279
+ : (updatedAt(oldest) || null);
157
280
  sessionIndexService.replaceDirectory({
158
281
  directory: task.directory,
159
- sessions: nextSessions,
160
- cursor: useIncremental ? (cached?.cursor ?? null) : (updatedAt(oldest) || null),
161
- hasMore: useIncremental ? Boolean(cached?.hasMore) : sessions.length === SESSION_LIMIT,
162
- fullSync: !useIncremental,
282
+ sessions: active,
283
+ cursor: cursorNumber,
284
+ hasMore: upstreamHasMore || hitPageBudget || active.length === SESSION_LIMIT,
285
+ fullSync: true,
163
286
  now: now(),
164
287
  });
165
- for (const session of nextSessions) {
288
+ for (const session of active) {
166
289
  if (!session?.id) continue;
167
290
  const key = `${task.runtimeKey}\n${task.directory}\n${session.id}`;
168
291
  if (queuedChildFlagKeys.has(key)) continue;
@@ -187,7 +310,10 @@ export const createSessionIndexSyncRuntime = ({
187
310
  currentWasPreempted = false;
188
311
  const timeout = setTimer(() => controller.abort(), FETCH_TIMEOUT_MS);
189
312
  try {
190
- const url = new URL(buildOpenCodeUrl(`/session/${encodeURIComponent(task.sessionID)}/children`));
313
+ // v2 protocol has no `/session/:id/children` route; child sessions are a
314
+ // `parentID` filter on the session list (`GET /api/session`).
315
+ const url = new URL(buildOpenCodeUrl('/session'));
316
+ url.searchParams.set('parentID', task.sessionID);
191
317
  url.searchParams.set('directory', task.directory);
192
318
  const response = await fetchFn(url, {
193
319
  method: 'GET',
@@ -199,8 +325,12 @@ export const createSessionIndexSyncRuntime = ({
199
325
  error.status = response.status;
200
326
  throw error;
201
327
  }
202
- const children = await response.json();
203
- if (!Array.isArray(children)) throw new Error('Invalid OpenCode child-session list payload');
328
+ const payload = await response.json();
329
+ // v2 wraps list responses in `{ data, cursor }`; accept a bare array too.
330
+ const children = Array.isArray(payload)
331
+ ? payload
332
+ : (Array.isArray(payload?.data) ? payload.data : null);
333
+ if (!children) throw new Error('Invalid OpenCode child-session list payload');
204
334
  sessionIndexService.replaceChildSessions(task.directory, task.sessionID, children);
205
335
  return 'completed';
206
336
  } catch (error) {
@@ -1,6 +1,6 @@
1
1
  import { describe, expect, it, vi } from 'vitest';
2
2
 
3
- import { createSessionIndexSyncRuntime } from './sync-runtime.js';
3
+ import { createSessionIndexSyncRuntime, extractSessionListCursorToken } from './sync-runtime.js';
4
4
 
5
5
  const session = (id, updated, directory = '/repo') => ({
6
6
  id,
@@ -68,7 +68,7 @@ describe('session index background sync runtime', () => {
68
68
 
69
69
  expect(maxActive).toBe(1);
70
70
  expect(fetchFn).toHaveBeenCalledTimes(4);
71
- expect(fetchFn.mock.calls[0][0].pathname).toBe('/experimental/session');
71
+ expect(fetchFn.mock.calls[0][0].pathname).toBe('/session');
72
72
  expect(service.replaceDirectory).toHaveBeenCalledTimes(2);
73
73
  expect(service.replaceChildSessions).toHaveBeenCalledTimes(2);
74
74
  expect(runtime.snapshot().sync).toMatchObject({ completed: 2, total: 2, failedDirectories: [] });
@@ -89,7 +89,161 @@ describe('session index background sync runtime', () => {
89
89
  await expect(changed).resolves.toMatchObject({ revision: initialRevision + 1 });
90
90
  });
91
91
 
92
- it('uses the persisted watermark for a true incremental merge', async () => {
92
+ it('extracts cursor.next without stringifying objects', () => {
93
+ expect(extractSessionListCursorToken({ next: 'abc' })).toBe('abc');
94
+ expect(extractSessionListCursorToken({ next: 42 })).toBe('42');
95
+ expect(extractSessionListCursorToken({ foo: 1 })).toBeNull();
96
+ expect(extractSessionListCursorToken('plain')).toBe('plain');
97
+ });
98
+
99
+ it('continues paging when a short page still carries cursor.next', async () => {
100
+ const service = createService();
101
+ const cursors = [];
102
+ const runtime = createSessionIndexSyncRuntime({
103
+ sessionIndexService: service,
104
+ buildOpenCodeUrl: (route) => `http://opencode.test${route}`,
105
+ getOpenCodeAuthHeaders: () => ({}),
106
+ waitForOpenCodeReady: async () => true,
107
+ projectSessions: (sessions) => sessions,
108
+ fetchFn: async (url) => {
109
+ if (url.pathname !== '/session') {
110
+ return new Response(JSON.stringify([]), { status: 200 });
111
+ }
112
+ cursors.push(url.searchParams.get('cursor'));
113
+ if (!url.searchParams.get('cursor')) {
114
+ // Short page (not limit=20) but v2 still offers next — must continue.
115
+ return new Response(JSON.stringify({
116
+ data: [
117
+ { ...session('ses_arch_a', 30), time: { created: 1, updated: 30, archived: 9 } },
118
+ { ...session('ses_arch_b', 29), time: { created: 1, updated: 29, archived: 9 } },
119
+ ],
120
+ cursor: { next: 'more-after-short' },
121
+ }), { status: 200 });
122
+ }
123
+ return new Response(JSON.stringify({
124
+ data: [session('ses_active', 10)],
125
+ cursor: null,
126
+ }), { status: 200 });
127
+ },
128
+ });
129
+ runtime.enqueue(['/repo']);
130
+ await waitUntil(() => isFullyIdle(runtime));
131
+ expect(cursors).toContain('more-after-short');
132
+ expect(service.replaceDirectory.mock.calls[0][0].sessions).toEqual([
133
+ expect.objectContaining({ id: 'ses_active' }),
134
+ ]);
135
+ });
136
+
137
+ it('uses cursor.next for paging and does not write [object Object]', async () => {
138
+ const service = createService();
139
+ const cursors = [];
140
+ const runtime = createSessionIndexSyncRuntime({
141
+ sessionIndexService: service,
142
+ buildOpenCodeUrl: (route) => `http://opencode.test${route}`,
143
+ getOpenCodeAuthHeaders: () => ({}),
144
+ waitForOpenCodeReady: async () => true,
145
+ projectSessions: (sessions) => sessions,
146
+ fetchFn: async (url) => {
147
+ if (url.pathname !== '/session') {
148
+ return new Response(JSON.stringify([]), { status: 200 });
149
+ }
150
+ cursors.push(url.searchParams.get('cursor'));
151
+ if (!url.searchParams.get('cursor')) {
152
+ const archivedPage = Array.from({ length: 20 }, (_, index) => ({
153
+ ...session(`ses_arch_${index}`, 100 - index),
154
+ time: { created: 1, updated: 100 - index, archived: 9 },
155
+ }));
156
+ return new Response(JSON.stringify({
157
+ data: archivedPage,
158
+ cursor: { next: 'page-2-token' },
159
+ }), { status: 200 });
160
+ }
161
+ return new Response(JSON.stringify({
162
+ data: [session('ses_active', 10)],
163
+ cursor: { next: null },
164
+ }), { status: 200 });
165
+ },
166
+ });
167
+
168
+ runtime.enqueue(['/repo']);
169
+ await waitUntil(() => isFullyIdle(runtime));
170
+ expect(cursors[0]).toBeNull();
171
+ expect(cursors).toContain('page-2-token');
172
+ expect(cursors.some((value) => value === '[object Object]')).toBe(false);
173
+ expect(service.replaceDirectory.mock.calls[0][0].sessions).toEqual([
174
+ expect.objectContaining({ id: 'ses_active' }),
175
+ ]);
176
+ });
177
+
178
+ it('keeps prior directory rows when projectSessions fails', async () => {
179
+ const service = createService([{
180
+ directory: '/repo',
181
+ sessions: [session('ses_old', 10)],
182
+ cursor: null,
183
+ hasMore: false,
184
+ }]);
185
+ const runtime = createSessionIndexSyncRuntime({
186
+ sessionIndexService: service,
187
+ buildOpenCodeUrl: (route) => `http://opencode.test${route}`,
188
+ getOpenCodeAuthHeaders: () => ({}),
189
+ waitForOpenCodeReady: async () => true,
190
+ projectSessions: () => {
191
+ throw new Error('metadata unavailable');
192
+ },
193
+ fetchFn: async () => new Response(JSON.stringify([session('ses_new', 20)]), { status: 200 }),
194
+ });
195
+ runtime.enqueue(['/repo']);
196
+ await waitUntil(() => isFullyIdle(runtime));
197
+ // replaceDirectory must not have replaced with empty/new on failure.
198
+ expect(service.replaceDirectory).not.toHaveBeenCalled();
199
+ expect(runtime.snapshot().sync.failedDirectories).toContain('/repo');
200
+ });
201
+
202
+ it('skips Host-archived rows and pages within budget to fill active slots', async () => {
203
+ const service = createService();
204
+ const urls = [];
205
+ const runtime = createSessionIndexSyncRuntime({
206
+ sessionIndexService: service,
207
+ buildOpenCodeUrl: (route) => `http://opencode.test${route}`,
208
+ getOpenCodeAuthHeaders: () => ({}),
209
+ waitForOpenCodeReady: async () => true,
210
+ projectSessions: (sessions) => sessions.map((item) => (
211
+ String(item.id).startsWith('ses_arch')
212
+ ? { ...item, time: { ...item.time, archived: 9 } }
213
+ : item
214
+ )),
215
+ fetchFn: async (url) => {
216
+ if (url.pathname !== '/session') {
217
+ return new Response(JSON.stringify([]), { status: 200 });
218
+ }
219
+ urls.push(url.searchParams.get('cursor'));
220
+ if (!url.searchParams.get('cursor')) {
221
+ // Full page of archived rows — sync must continue with cursor.
222
+ const archivedPage = Array.from({ length: 20 }, (_, index) => session(`ses_arch_${index}`, 100 - index));
223
+ return new Response(JSON.stringify({
224
+ data: archivedPage,
225
+ cursor: 'page-2',
226
+ }), { status: 200 });
227
+ }
228
+ return new Response(JSON.stringify({
229
+ data: [session('ses_active', 10)],
230
+ cursor: null,
231
+ }), { status: 200 });
232
+ },
233
+ });
234
+
235
+ runtime.enqueue(['/repo']);
236
+ await waitUntil(() => isFullyIdle(runtime));
237
+
238
+ expect(urls[0]).toBeNull();
239
+ expect(urls).toContain('page-2');
240
+ expect(service.replaceDirectory.mock.calls[0][0].sessions).toEqual([
241
+ expect.objectContaining({ id: 'ses_active' }),
242
+ ]);
243
+ expect(service.replaceDirectory.mock.calls[0][0].sessions.some((item) => String(item.id).startsWith('ses_arch'))).toBe(false);
244
+ });
245
+
246
+ it('v2 session.list has no incremental start — always fetches a full page', async () => {
93
247
  const service = createService([{
94
248
  directory: '/repo',
95
249
  sessions: [session('ses_old', 10)],
@@ -105,7 +259,7 @@ describe('session index background sync runtime', () => {
105
259
  getOpenCodeAuthHeaders: () => ({}),
106
260
  waitForOpenCodeReady: async () => true,
107
261
  fetchFn: async (url) => {
108
- if (url.pathname === '/experimental/session') requestedUrl = url;
262
+ if (url.pathname === '/session') requestedUrl = url;
109
263
  return new Response(JSON.stringify([session('ses_new', 20)]), { status: 200 });
110
264
  },
111
265
  now: () => 2000,
@@ -114,10 +268,11 @@ describe('session index background sync runtime', () => {
114
268
  runtime.enqueue(['/repo']);
115
269
  await waitUntil(() => isFullyIdle(runtime));
116
270
 
117
- expect(requestedUrl.searchParams.get('start')).toBe('1000');
271
+ expect(requestedUrl.searchParams.get('start')).toBeNull();
272
+ expect(requestedUrl.searchParams.get('roots')).toBeNull();
118
273
  expect(service.replaceDirectory.mock.calls[0][0]).toMatchObject({
119
- fullSync: false,
120
- sessions: [expect.objectContaining({ id: 'ses_new' }), expect.objectContaining({ id: 'ses_old' })],
274
+ fullSync: true,
275
+ sessions: [expect.objectContaining({ id: 'ses_new' })],
121
276
  });
122
277
  });
123
278
 
@@ -195,7 +350,7 @@ describe('session index background sync runtime', () => {
195
350
  });
196
351
  });
197
352
 
198
- it('omits start when recovering an empty cached directory', async () => {
353
+ it('recovers an empty cached directory with a full page', async () => {
199
354
  const service = createService([{
200
355
  directory: '/repo',
201
356
  sessions: [],
@@ -211,7 +366,7 @@ describe('session index background sync runtime', () => {
211
366
  getOpenCodeAuthHeaders: () => ({}),
212
367
  waitForOpenCodeReady: async () => true,
213
368
  fetchFn: async (url) => {
214
- if (url.pathname === '/experimental/session') requestedUrl = url;
369
+ if (url.pathname === '/session') requestedUrl = url;
215
370
  return new Response(JSON.stringify([session('ses_history', 10)]), { status: 200 });
216
371
  },
217
372
  now: () => 2000,
@@ -0,0 +1,86 @@
1
+ # Session metadata store
2
+
3
+ OpenChamber-owned per-session metadata for Host features that used to live on
4
+ OpenCode `session.metadata`.
5
+
6
+ ## Why
7
+
8
+ Session goal progress, Host archive stamps, and later other Host state under the
9
+ `openchamber` namespace are OpenChamber product state. The Host owns their
10
+ authority: it serializes conditional writes, publishes only persisted values,
11
+ and protects archive fields from generic patches. They therefore live in this
12
+ Host store and are folded back onto proxy session list/detail/SSE responses so
13
+ clients still read `session.metadata` and `time.archived`.
14
+
15
+ Upstream capability is tracked per version and does not change that ownership:
16
+
17
+ | OpenCode client | Session metadata update |
18
+ |---|---|
19
+ | Earlier 2.x releases used during the migration | `metadata` accepted at create time only |
20
+ | 2.0.14 | `PATCH /api/session/:id` with body `{ title, metadata, permissions }` (`session.update`) |
21
+
22
+ Adopting the upstream update API for Host-owned fields requires a separate
23
+ decision about authority, conditional writes, and archive protection.
24
+
25
+ ## Layout
26
+
27
+ | File | Role |
28
+ |---|---|
29
+ | `session-metadata-store.js` | Durable store: one JSON file per data dir, `{ [sessionID]: metadata }`, RFC 7386 merge patch, full RMW serialization, refuse-failed-read refuses write/read-as-empty |
30
+ | `session-projection.js` | Pure Host authority projection (metadata deep-merge + archive → `time.archived`) |
31
+ | `session-archive.js` | Domain archive/unarchive: validate upstream session + directory, persist, project, best-effort index/broadcast |
32
+ | `system-session.js` | Isolation patches for system session creators: `buildScheduledTaskMetadata`, `buildLlmSessionMetadata`, `buildAssistantSessionMetadata`. Title prefixes are labels and never ownership. |
33
+ | `routes.js` | `PUT .../metadata`, `PUT .../archive`, re-exports store/archive/projection |
34
+ | `*.d.ts` | Sibling type surfaces for Extension Host (`packages/vscode` facades) — runtime stays in `.js` |
35
+ | `*.test.js` | Store + route + archive contracts |
36
+
37
+ ## Contracts
38
+
39
+ - Writes are JSON Merge Patch (RFC 7386): nested objects merge key-by-key; `null` deletes a key.
40
+ - Mutations are fully serialized (read-merge-write + persist) so concurrent writers cannot lose neighbors.
41
+ - Committed `entries` update **only after** successful persist — concurrent readers never observe uncommitted drafts. Persist failure keeps the last committed snapshot and surfaces the error.
42
+ - `mutateSessionMetadata(sessionID, decide)` runs `decide(committedMetadata)` on the same exclusive lock. Return `{ ok: false, reason? }` to reject without writing, or `{ ok: true, patch }` to merge-persist-publish. Session-goal uses this for generation/status preconditions.
43
+ - Goal-shaped metadata PUT patches advance `openchamber.goal.executionGeneration` via `withGoalExecutionGeneration` inside the mutate lock (pause/resume/condition changes).
44
+ - A failed or **corrupt** file read is **not** empty success — the store stays unwritable, keeps the file, and throws; it does not overwrite unknown disk state with `{}`.
45
+ - Entries are keyed by `sessionID` only (not directory). Runtime isolation is the Host data dir. Multiple Hosts sharing one data file are outside the current contract.
46
+ - Generic metadata patches **cannot** wipe Host archive: `openchamber.archive` is stripped from the patch and restored after merge (including null ancestor deletes). Archive writes use `setSessionMetadata(..., { allowArchive: true })`.
47
+ - Host archive authority (`metadata.openchamber.archive.archivedAt`):
48
+ - positive number → archived (`time.archived` = that value)
49
+ - `0` → explicit unarchive (clears `time.archived` even if upstream still has history)
50
+ - missing → fall back to upstream `time.archived`
51
+ - `PUT /api/openchamber/sessions/:sessionId/archive` body `{ archivedAt: number, directory?: string }` returns `{ session, index? }`. Validates upstream id + directory (`location.directory` first). Does not mutate upstream `created`/`updated`. Metadata commit is authoritative; `index: { ok: false, retryable: true }` is observable when SQLite apply fails after commit (`retryIndexUpdate` / `getIndexRepairStatus`).
52
+ - When the Host store is wired, proxy list/get return **503 retryable** on metadata read failure (never silent un-overlaid upstream). Until the store is ready, session lifecycle SSE/hub frames are suppressed; transcript/message paths stay open. Hub `replayAfter` re-projects with **current** committed metadata.
53
+ - Successful proxy `DELETE /api/session/:id` and assistant SDK delete run idempotent `forgetSession`; `session.deleted` remains compensatory. Upstream delete failure keeps metadata.
54
+ - Successful metadata `PUT` may broadcast `openchamber:session-metadata` with the **full** merged metadata object.
55
+ - Successful archive broadcasts `session.updated` with projected `info` (global UI + openchamber SSE).
56
+ - `session.deleted` events best-effort `removeSession` metadata cleanup.
57
+
58
+ ## Server wiring
59
+
60
+ Wired from `packages/web/server/index.js` + `feature-routes-runtime.js`:
61
+
62
+ 1. `createSessionMetadataStore({ dataDir: OPENCHAMBER_DATA_DIR })` + initial `load()`
63
+ 2. proxy `getStoredSessionMetadata` / `getStoredSessionMetadataSync` → list/get + direct SSE overlay
64
+ 3. global hub `projectOutboundSessionPayload` → live + replay session lifecycle authority
65
+ 4. session-index sync `projectSessions` + event-ingest `projectSession` / `onSessionDeleted`
66
+ 5. `createSessionArchiveService` after session index exists
67
+ 6. session-goal `readSessionMetadata` / `mutateSessionMetadata` / `persistSessionGoal`
68
+ 7. `registerSessionMetadataRoutes` (metadata + archive)
69
+ 8. assistants `archiveSessionHost` → shared Host archive (not upstream `session.update`)
70
+ 9. system session creators persist isolation through `persistSessionMetadata`
71
+ (`setSessionMetadata` + `openchamber:session-metadata` broadcast) and then
72
+ `onSystemSessionPersisted` (index upsert that removes the row). Wired from
73
+ `index.js` into scheduled-task runtime, LLM attachment generate, and
74
+ Assistant binding create. OpenCode create-time `metadata` is a hint only.
75
+
76
+ Capability is open (`supported: true`). Manual UI metadata writes, session-goal
77
+ routes, and scheduled-task goal create all persist `openchamber.goal` through
78
+ this store. After first create/resume persist, the server notifies the goal
79
+ runtime with a synthetic `session.updated` so the loop can arm.
80
+
81
+ Sidebar / session-index / notifications hide a session only when Host metadata
82
+ carries a non-empty `openchamber.assistant.assistantID` (unless
83
+ `assigned.from === 'contact'`), `openchamber.scheduledTask.taskID`,
84
+ `openchamber.smallModel.purpose`, or `openchamber.llm.purpose`. Title prefixes
85
+ are human labels and never participate. Every system session creator must
86
+ persist the matching patch through this store.