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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (287) hide show
  1. package/README.md +15 -11
  2. package/bin/cli-opencode.test.js +4 -4
  3. package/bin/cli.js +1 -1
  4. package/bin/lib/cli-startup.js +1 -1
  5. package/bin/lib/cli-startup.test.js +18 -4
  6. package/dist/assets/{AgentAvatar-CLWWNu_Q.js → AgentAvatar-Q1SmFRx2.js} +1 -1
  7. package/dist/assets/AssistantView-CwXkiCAd.js +3 -0
  8. package/dist/assets/{DiagramView-xuSJTnv7.js → DiagramView-BW92schO.js} +1 -1
  9. package/dist/assets/{FadeInOnReveal-Ov0D96d0.js → FadeInOnReveal-BZmwl-zE.js} +1 -1
  10. package/dist/assets/{Icon-DD-BuMfZ.js → Icon-AQPvxMiU.js} +1 -1
  11. package/dist/assets/{MarkdownRenderer-bc_p1H7l.js → MarkdownRenderer-Bv05iDZZ.js} +2 -2
  12. package/dist/assets/{MarkdownRendererImpl-SKi4oG0c.js → MarkdownRendererImpl-C81xqYib.js} +3 -3
  13. package/dist/assets/MarkstreamRendererImpl-CXUCQuKJ.js +3 -0
  14. package/dist/assets/{MobileDetailNavigation-Bnx59U_J.js → MobileDetailNavigation-BMgIu7zc.js} +1 -1
  15. package/dist/assets/{MobileShareBridge-BQlP4J-F.js → MobileShareBridge-BOXGNHmC.js} +2 -2
  16. package/dist/assets/MobileSurface-xuc_z0_G.js +2 -0
  17. package/dist/assets/{MultiRunWindow-ChZGob0v.js → MultiRunWindow-B5At2Z95.js} +1 -1
  18. package/dist/assets/{OnboardingScreen-DtQJxZG-.js → OnboardingScreen-bsPLgz4r.js} +1 -1
  19. package/dist/assets/OverlayScrollbar-4wp4migh.js +2 -0
  20. package/dist/assets/SettingsView-CJ6tcH5_.js +25 -0
  21. package/dist/assets/{SettingsWindow-DfnsFa8y.js → SettingsWindow-CMtX2bMl.js} +1 -1
  22. package/dist/assets/{TerminalView-Dh9MWCpS.js → TerminalView-Dvr-ysTS.js} +1 -1
  23. package/dist/assets/{ToolOutputDialog-BejmjrRk.js → ToolOutputDialog-uH1ZZDSS.js} +2 -2
  24. package/dist/assets/appThemeRegistry-DK7q2juD.js +1 -0
  25. package/dist/assets/{assistant-attachment-cache-C0aRnFkj.js → assistant-attachment-cache-DphJleHr.js} +1 -1
  26. package/dist/assets/{button-D-rATN7w.js → button-CcNJUxUd.js} +1 -1
  27. package/dist/assets/collapsible-ZmZ6rLWs.js +3 -0
  28. package/dist/assets/context-menu-B2LXJgXL.js +1 -0
  29. package/dist/assets/{desktopHosts-B1ixEylr.js → desktopHosts-C6HrP0jq.js} +1 -1
  30. package/dist/assets/{desktopRelayRestore-4JmFUw8r.js → desktopRelayRestore-DdVBp45U.js} +1 -1
  31. package/dist/assets/{diagram-DQf7Y_uR.js → diagram-CxTs9hCQ.js} +1 -1
  32. package/dist/assets/{dialog-bfbvR4VH.js → dialog-KMVL9QKF.js} +1 -1
  33. package/dist/assets/es-CN3E_zXj.js +15 -0
  34. package/dist/assets/{fileMentionTouchSelection-BvrNCroG.js → fileMentionTouchSelection-7RIwlpkB.js} +1 -1
  35. package/dist/assets/{fileQueries-DQc8EJUf.js → fileQueries-CjdBRy8y.js} +1 -1
  36. package/dist/assets/fr-9zZvQZD2.js +13 -0
  37. package/dist/assets/gitApi-yuWg9BN4.js +10 -0
  38. package/dist/assets/{imageSource-BlAJJUEI.js → imageSource-9vQBq3zp.js} +1 -1
  39. package/dist/assets/{input-8r6F_Fza.js → input-CrIvk3HZ.js} +1 -1
  40. package/dist/assets/insertionBoundaries-BiUCSkx-.js +1 -0
  41. package/dist/assets/ja-Dnjk_82N.js +15 -0
  42. package/dist/assets/ko-BxCEE99C.js +15 -0
  43. package/dist/assets/{main-CCE_4y5F.js → main-C9mIBo77.js} +2 -2
  44. package/dist/assets/main-DiL6w6KF.js +169 -0
  45. package/dist/assets/markdown-worker-bS5OgGO3.js +2 -0
  46. package/dist/assets/{markdownHeightCache-1Qo6y6IJ.js → markdownHeightCache-BmXVY_gc.js} +1 -1
  47. package/dist/assets/{markdownTheme-DOfGg-A7.js → markdownTheme-CKac3wKC.js} +1 -1
  48. package/dist/assets/mcpQueries-C7jknHV8.js +1 -0
  49. package/dist/assets/miniChat-CwtUaElU.js +2 -0
  50. package/dist/assets/mobile-DB-6--ws.js +2 -0
  51. package/dist/assets/multirun-TAsxFI8e.js +4 -0
  52. package/dist/assets/{persistence-Ce3AnpEr.js → persistence-56kq8Z4V.js} +2 -2
  53. package/dist/assets/pl-CNNsWnPM.js +15 -0
  54. package/dist/assets/platform-C13fMVEs.js +6 -0
  55. package/dist/assets/pt-BR-BZB2DJMp.js +15 -0
  56. package/dist/assets/{relay-image-stream-DM7atqJs.js → relay-image-stream-DrdVj99c.js} +1 -1
  57. package/dist/assets/renderElectronMiniChatApp-CGj0MNRU.js +2 -0
  58. package/dist/assets/renderMobileApp-CGMMqbsO.js +28 -0
  59. package/dist/assets/runtime-fetch-Fs9ZV2V8.js +1 -0
  60. package/dist/assets/{runtimeConfig-BMvqa5we.js → runtimeConfig-BEANkvOd.js} +1 -1
  61. package/dist/assets/runtimeEndpointReset-A_ZBUJFS.js +147 -0
  62. package/dist/assets/sessionChangeSummary-qOLrP9fw.js +2 -0
  63. package/dist/assets/{sessionLookup-gh-hVRIc.js → sessionLookup-34XFmP9C.js} +1 -1
  64. package/dist/assets/{sortable-tabs-strip-CudoSgV4.js → sortable-tabs-strip-DyEV0Heg.js} +1 -1
  65. package/dist/assets/src-ubCWXZhR.css +2 -0
  66. package/dist/assets/store-DLCiZaA6.js +15 -0
  67. package/dist/assets/{terminalApi-DiF6JmBP.js → terminalApi-CXaBEoa_.js} +1 -1
  68. package/dist/assets/textarea-BBOlRI9O.js +1 -0
  69. package/dist/assets/{tooltip-CQvpK-UV.js → tooltip-CPI4fr1a.js} +1 -1
  70. package/dist/assets/uk-rwQrxeYM.js +15 -0
  71. package/dist/assets/{url-_z7evzdK.js → url-DRDQXjyX.js} +1 -1
  72. package/dist/assets/{useAppFontEffects-Cpmpo7Wb.js → useAppFontEffects-CiDe42I5.js} +57 -43
  73. package/dist/assets/{useAssistantUIStore-CMutVh9B.js → useAssistantUIStore-asNG6K7n.js} +1 -1
  74. package/dist/assets/useDesktopWindowControlsLayout-BlizAlOt.js +1 -0
  75. package/dist/assets/useEffectiveDirectory-C_CouL5v.js +1 -0
  76. package/dist/assets/{useFontPreferences-DcbXQOkc.js → useFontPreferences-B0oUZAiB.js} +1 -1
  77. package/dist/assets/useMobileNavigationStore-DfPClaW-.js +414 -0
  78. package/dist/assets/{useRuntimeAPIs-DSLXJybW.js → useRuntimeAPIs-DwLtO0c_.js} +1 -1
  79. package/dist/assets/useSessionAutoCleanup-DtrfbxAp.js +2 -0
  80. package/dist/assets/{useThemeSystem-DMzWVJaW.js → useThemeSystem-BlNUWH47.js} +1 -1
  81. package/dist/assets/useUIStore-CQYKJnrg.js +1 -0
  82. package/dist/assets/{useWorkerHighlightedLines-Bqn4AiBL.js → useWorkerHighlightedLines-CPNYz6Wf.js} +2 -2
  83. package/dist/assets/{vendor-base-ui-CnHfUmDN.js → vendor-base-ui-CDjyofeD.js} +1 -1
  84. package/dist/assets/{vendor-cmdk-CeAGfrLo.js → vendor-cmdk-BFH0UpXX.js} +1 -1
  85. package/dist/assets/{vendor-dnd-kit-accessibility-BY5LhhUB.js → vendor-dnd-kit-accessibility-BrR3AKvM.js} +1 -1
  86. package/dist/assets/{vendor-dnd-kit-core-CvWxUPdb.js → vendor-dnd-kit-core-CNfdvk6H.js} +1 -1
  87. package/dist/assets/{vendor-dnd-kit-sortable-hHdP9lLb.js → vendor-dnd-kit-sortable-DQUaTeGj.js} +1 -1
  88. package/dist/assets/{vendor-framer-motion-DFy_BSVd.js → vendor-framer-motion-4dogsUk3.js} +1 -1
  89. package/dist/assets/{vendor-legendapp-list-WE32lwNk.js → vendor-legendapp-list-BU7MdPZR.js} +1 -1
  90. package/dist/assets/{vendor-markstream-react-DX55XLGO.js → vendor-markstream-react-qHC2FykU.js} +3 -3
  91. package/dist/assets/{vendor-pierre-diffs-DX7XTPG3.js → vendor-pierre-diffs-CUfqC003.js} +1 -1
  92. package/dist/assets/{vendor-react-DLPElpoQ.js → vendor-react-C94A0AHl.js} +1 -1
  93. package/dist/assets/{vendor-react-drawio-ECtEwvbc.js → vendor-react-drawio-FVlWqKVE.js} +1 -1
  94. package/dist/assets/{vendor-reactuses-core-BnW6GxJt.js → vendor-reactuses-core-C2A3EjUi.js} +1 -1
  95. package/dist/assets/{vendor-sonner-DVzioF3d.js → vendor-sonner-DNqHmUQs.js} +1 -1
  96. package/dist/assets/{vendor-stream-diffs-DWy3xSmp.js → vendor-stream-diffs-CwbKsmlv.js} +2 -2
  97. package/dist/assets/{vendor-tanstack-react-query-D7pys_c9.js → vendor-tanstack-react-query-D0iFK57C.js} +1 -1
  98. package/dist/assets/{vendor-tanstack-react-virtual-tlDgH6PG.js → vendor-tanstack-react-virtual-B2kV6cnT.js} +1 -1
  99. package/dist/assets/{vendor-zustand-B4dlGEM2.js → vendor-zustand-BqLKf6ZY.js} +1 -1
  100. package/dist/assets/zh-CN-BvmBDnkD.js +15 -0
  101. package/dist/assets/zh-TW-9wcHTX0y.js +15 -0
  102. package/dist/index.html +13 -13
  103. package/dist/mini-chat.html +13 -13
  104. package/dist/mobile.html +13 -13
  105. package/package.json +1 -1
  106. package/server/index.js +327 -62
  107. package/server/lib/assistants/DOCUMENTATION.md +14 -9
  108. package/server/lib/assistants/contracts.js +11 -1
  109. package/server/lib/assistants/projection-history.test.js +265 -18
  110. package/server/lib/assistants/routes.js +6 -0
  111. package/server/lib/assistants/service.js +417 -231
  112. package/server/lib/assistants/service.test.js +392 -148
  113. package/server/lib/conversations/DOCUMENTATION.md +1 -1
  114. package/server/lib/conversations/service.js +1 -0
  115. package/server/lib/conversations/service.test.js +16 -0
  116. package/server/lib/conversations/validation.js +11 -1
  117. package/server/lib/event-stream/DOCUMENTATION.md +3 -1
  118. package/server/lib/event-stream/directory-ws-bridge.js +13 -2
  119. package/server/lib/event-stream/global-hub.js +49 -4
  120. package/server/lib/event-stream/reasoning-projection.js +16 -1
  121. package/server/lib/event-stream/reasoning-projection.test.js +15 -0
  122. package/server/lib/event-stream/runtime.js +57 -20
  123. package/server/lib/event-stream/runtime.test.js +112 -0
  124. package/server/lib/llm/DOCUMENTATION.md +33 -9
  125. package/server/lib/llm/catalog.js +1 -1
  126. package/server/lib/llm/completions.js +5 -1
  127. package/server/lib/llm/completions.test.js +3 -3
  128. package/server/lib/llm/generate.js +237 -145
  129. package/server/lib/llm/generate.test.js +201 -46
  130. package/server/lib/llm/routes.js +2 -0
  131. package/server/lib/message-queue/runtime.js +15 -2
  132. package/server/lib/notifications/runtime.js +23 -5
  133. package/server/lib/notifications/runtime.test.js +11 -0
  134. package/server/lib/opencode/DOCUMENTATION.md +85 -17
  135. package/server/lib/opencode/agent-document.d.ts +28 -0
  136. package/server/lib/opencode/agent-document.js +280 -0
  137. package/server/lib/opencode/agent-document.test.js +137 -0
  138. package/server/lib/opencode/agents.js +96 -0
  139. package/server/lib/opencode/auth-state-runtime.js +5 -0
  140. package/server/lib/opencode/bootstrap-runtime.js +2 -0
  141. package/server/lib/opencode/config-entity-routes.js +42 -99
  142. package/server/lib/opencode/config-entity-routes.test.js +107 -0
  143. package/server/lib/opencode/config-hot-reload.integration.test.js +105 -0
  144. package/server/lib/opencode/ensure-cli.js +1 -2
  145. package/server/lib/opencode/env-runtime.js +40 -26
  146. package/server/lib/opencode/env-runtime.test.js +10 -17
  147. package/server/lib/opencode/feature-routes-runtime.js +43 -4
  148. package/server/lib/opencode/feature-routes-runtime.test.js +18 -0
  149. package/server/lib/opencode/http-server-connections.js +98 -0
  150. package/server/lib/opencode/http-server-connections.test.js +321 -0
  151. package/server/lib/opencode/lifecycle.js +349 -39
  152. package/server/lib/opencode/lifecycle.test.js +322 -0
  153. package/server/lib/opencode/managed-capabilities-runtime.js +15 -2
  154. package/server/lib/opencode/managed-capabilities-runtime.test.js +1 -0
  155. package/server/lib/opencode/openchamber-routes.js +7 -1
  156. package/server/lib/opencode/opencode2-pin.js +4 -4
  157. package/server/lib/opencode/opencode2-pin.test.js +1 -0
  158. package/server/lib/opencode/opencode2-sidecar.e2e.test.js +127 -4
  159. package/server/lib/opencode/owned-runtime-upgrade.js +431 -0
  160. package/server/lib/opencode/owned-runtime-upgrade.test.js +160 -0
  161. package/server/lib/opencode/plugin-routes.js +3 -18
  162. package/server/lib/opencode/plugin-routes.test.js +9 -9
  163. package/server/lib/opencode/provider-catalog.js +58 -12
  164. package/server/lib/opencode/provider-catalog.test.js +48 -0
  165. package/server/lib/opencode/proxy.js +252 -46
  166. package/server/lib/opencode/proxy.test.js +235 -2
  167. package/server/lib/opencode/routes.js +296 -62
  168. package/server/lib/opencode/routes.test.js +278 -12
  169. package/server/lib/opencode/runtime-contract.js +401 -0
  170. package/server/lib/opencode/runtime-contract.test.js +214 -0
  171. package/server/lib/opencode/server-opencode-fetch.js +242 -0
  172. package/server/lib/opencode/server-opencode-fetch.test.js +130 -0
  173. package/server/lib/opencode/server-utils-runtime.js +33 -0
  174. package/server/lib/opencode/shared-service.integration.test.js +66 -0
  175. package/server/lib/opencode/shared-service.js +147 -0
  176. package/server/lib/opencode/shared-service.test.js +93 -0
  177. package/server/lib/opencode/shared.js +3 -3
  178. package/server/lib/opencode/shutdown-runtime.js +15 -6
  179. package/server/lib/opencode/shutdown-runtime.test.js +49 -1
  180. package/server/lib/opencode/skill-routes.js +20 -33
  181. package/server/lib/opencode/skill-routes.test.js +35 -2
  182. package/server/lib/opencode/skills.test.js +15 -0
  183. package/server/lib/opencode/startup-pipeline-runtime.js +4 -0
  184. package/server/lib/opencode/static-routes-runtime.js +3 -1
  185. package/server/lib/opencode/static-routes-runtime.test.js +33 -0
  186. package/server/lib/opencode/v2-client.js +14 -3
  187. package/server/lib/opencode/v2-plugin-host-shim/index.mjs +155 -0
  188. package/server/lib/opencode/v2-plugin-host-shim/package.json +4 -0
  189. package/server/lib/permission-auto-accept/DOCUMENTATION.md +5 -5
  190. package/server/lib/permission-auto-accept/runtime.js +14 -7
  191. package/server/lib/permission-auto-accept/runtime.test.js +27 -12
  192. package/server/lib/relay/reasoning-projection.e2e.test.ts +22 -7
  193. package/server/lib/scheduled-tasks/DOCUMENTATION.md +8 -0
  194. package/server/lib/scheduled-tasks/runtime.js +52 -4
  195. package/server/lib/scheduled-tasks/runtime.test.js +80 -1
  196. package/server/lib/session-goal/DOCUMENTATION.md +113 -13
  197. package/server/lib/session-goal/routes.js +9 -0
  198. package/server/lib/session-goal/runtime.js +1579 -117
  199. package/server/lib/session-goal/runtime.test.js +1453 -84
  200. package/server/lib/session-index/DOCUMENTATION.md +18 -15
  201. package/server/lib/session-index/event-ingest.js +89 -16
  202. package/server/lib/session-index/event-ingest.test.js +73 -0
  203. package/server/lib/session-index/host-authority-snapshot.js +92 -0
  204. package/server/lib/session-index/routes.host-archive.test.js +201 -0
  205. package/server/lib/session-index/routes.js +63 -10
  206. package/server/lib/session-index/routes.test.js +8 -8
  207. package/server/lib/session-index/service.js +7 -1
  208. package/server/lib/session-index/sync-runtime.js +161 -34
  209. package/server/lib/session-index/sync-runtime.test.js +155 -1
  210. package/server/lib/session-metadata/DOCUMENTATION.md +61 -16
  211. package/server/lib/session-metadata/routes.js +108 -9
  212. package/server/lib/session-metadata/routes.test.js +47 -2
  213. package/server/lib/session-metadata/session-archive.d.ts +73 -0
  214. package/server/lib/session-metadata/session-archive.js +654 -0
  215. package/server/lib/session-metadata/session-archive.test.js +359 -0
  216. package/server/lib/session-metadata/session-metadata-store.d.ts +36 -0
  217. package/server/lib/session-metadata/session-metadata-store.js +212 -72
  218. package/server/lib/session-metadata/session-metadata-store.test.js +205 -10
  219. package/server/lib/session-metadata/session-projection.d.ts +41 -0
  220. package/server/lib/session-metadata/session-projection.js +255 -0
  221. package/server/lib/session-metadata/session-projection.test.js +64 -0
  222. package/server/lib/session-metadata/system-session.js +55 -0
  223. package/server/lib/session-metadata/system-session.test.js +55 -0
  224. package/server/lib/session-title/DOCUMENTATION.md +40 -7
  225. package/server/lib/session-title/native-runtime.test.js +167 -0
  226. package/server/lib/session-title/runtime.js +67 -42
  227. package/server/lib/session-title/session-access.js +46 -0
  228. package/server/lib/session-title/session-access.test.js +91 -0
  229. package/server/lib/session-turn-pages/DOCUMENTATION.md +9 -8
  230. package/server/lib/session-turn-pages/routes.js +17 -102
  231. package/server/lib/session-turn-pages/routes.test.js +281 -0
  232. package/server/lib/session-turn-pages/session-message-projection.d.ts +13 -0
  233. package/server/lib/session-turn-pages/session-message-projection.js +281 -0
  234. package/server/lib/small-model/DOCUMENTATION.md +101 -147
  235. package/server/lib/small-model/catalog.js +31 -51
  236. package/server/lib/small-model/catalog.test.js +65 -10
  237. package/server/lib/small-model/custom-api.js +68 -0
  238. package/server/lib/small-model/custom-api.test.js +64 -0
  239. package/server/lib/small-model/index.js +124 -240
  240. package/server/lib/small-model/resolve.js +35 -120
  241. package/server/lib/small-model/resolve.test.js +20 -110
  242. package/server/lib/small-model/routes.js +1 -3
  243. package/server/lib/small-model/summary-settings.test.js +157 -253
  244. package/server/opencode-proxy.test.js +2 -0
  245. package/dist/assets/AssistantView-BTCkm8yk.js +0 -3
  246. package/dist/assets/MarkstreamRendererImpl-CnkodGJW.js +0 -3
  247. package/dist/assets/MobileSurface-BXLeiZ4d.js +0 -2
  248. package/dist/assets/OverlayScrollbar-pMwxaCca.js +0 -2
  249. package/dist/assets/SettingsView-B5I0KDTZ.js +0 -25
  250. package/dist/assets/appThemeRegistry-COGfxTFV.js +0 -1
  251. package/dist/assets/collapsible-BXRd5_Hr.js +0 -1
  252. package/dist/assets/context-menu-jGtH6fR9.js +0 -1
  253. package/dist/assets/es-c52LTHHe.js +0 -15
  254. package/dist/assets/fr-PisFbUE8.js +0 -13
  255. package/dist/assets/gitApi-ButwzQ0N.js +0 -9
  256. package/dist/assets/insertionBoundaries-DGs7S5zv.js +0 -1
  257. package/dist/assets/ja-C8Ikun3Z.js +0 -15
  258. package/dist/assets/ko-ClFys5o2.js +0 -15
  259. package/dist/assets/main-Dn9rpsgl.js +0 -169
  260. package/dist/assets/markdown-worker-CptoICmq.js +0 -2
  261. package/dist/assets/miniChat-BpjxVqgg.js +0 -2
  262. package/dist/assets/mobile-nMJCaNq1.js +0 -2
  263. package/dist/assets/multirun-BvSG94mW.js +0 -4
  264. package/dist/assets/pl-CnaKWN5F.js +0 -15
  265. package/dist/assets/platform-CWTIp02q.js +0 -4
  266. package/dist/assets/projectMeta-DOe49dM_.js +0 -3
  267. package/dist/assets/pt-BR-s8OpfRI6.js +0 -15
  268. package/dist/assets/renderElectronMiniChatApp-R6dKh79f.js +0 -2
  269. package/dist/assets/renderMobileApp-y-S8y8v_.js +0 -28
  270. package/dist/assets/runtime-fetch-zIalUnbb.js +0 -1
  271. package/dist/assets/runtimeEndpointReset-B6BxhvR0.js +0 -147
  272. package/dist/assets/sessionChangeSummary-VVBlEXuh.js +0 -2
  273. package/dist/assets/src-BzX5UI-c.css +0 -2
  274. package/dist/assets/store-DJczmczv.js +0 -15
  275. package/dist/assets/textarea-3XR8sUfy.js +0 -1
  276. package/dist/assets/uk-CaX0iqkB.js +0 -15
  277. package/dist/assets/useDesktopWindowControlsLayout-B1GX59-L.js +0 -1
  278. package/dist/assets/useEffectiveDirectory-CN6JoCFw.js +0 -1
  279. package/dist/assets/useMobileNavigationStore-BVjFtXSy.js +0 -414
  280. package/dist/assets/useSessionAutoCleanup-BLjymaFt.js +0 -2
  281. package/dist/assets/useUIStore-Bxv3f23A.js +0 -1
  282. package/dist/assets/zh-CN-20MYEjha.js +0 -15
  283. package/dist/assets/zh-TW-DiN04qTw.js +0 -15
  284. package/server/lib/small-model/call.js +0 -427
  285. package/server/lib/small-model/call.test.js +0 -382
  286. package/server/lib/small-model/opencode-session.js +0 -183
  287. package/server/lib/small-model/opencode-session.test.js +0 -147
@@ -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,49 +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
- // v2 `session.list` (`GET /api/session`) exposes cursor pagination only —
121
- // no timestamp-incremental (`start`) or `roots` filter. Sync therefore
122
- // always fetches a fresh full page and lets replaceDirectory reconcile.
123
- // Empty snapshots still store lastSyncedAt as a worktree topology hint.
124
- const useIncremental = false;
125
- const url = new URL(buildOpenCodeUrl('/session'));
126
- url.searchParams.set('directory', task.directory);
127
- url.searchParams.set('limit', String(SESSION_LIMIT));
128
-
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.
129
174
  const controller = new AbortController();
130
175
  currentController = controller;
131
176
  currentWasPreempted = false;
132
177
  const timeout = setTimer(() => controller.abort(), FETCH_TIMEOUT_MS);
133
178
  try {
134
- const response = await fetchFn(url, {
135
- method: 'GET',
136
- headers: { Accept: 'application/json', ...getOpenCodeAuthHeaders() },
137
- signal: controller.signal,
138
- });
139
- if (!response.ok) {
140
- const error = new Error(`OpenCode session list failed (${response.status})`);
141
- error.status = response.status;
142
- 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;
143
250
  }
144
- const payload = await response.json();
145
- // v2 responds `{ data: Session[], cursor }`; tolerate a bare array for
146
- // non-v2 test doubles.
147
- const sessions = Array.isArray(payload) ? payload : payload?.data;
148
- if (!Array.isArray(sessions)) throw new Error('Invalid OpenCode session list payload');
149
- const nextSessions = useIncremental
150
- ? mergeIncrementalSessions(cached?.sessions, sessions)
151
- : sessions.slice(0, SESSION_LIMIT);
152
- 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);
153
280
  sessionIndexService.replaceDirectory({
154
281
  directory: task.directory,
155
- sessions: nextSessions,
156
- cursor: useIncremental ? (cached?.cursor ?? null) : (updatedAt(oldest) || null),
157
- hasMore: useIncremental ? Boolean(cached?.hasMore) : sessions.length === SESSION_LIMIT,
158
- fullSync: !useIncremental,
282
+ sessions: active,
283
+ cursor: cursorNumber,
284
+ hasMore: upstreamHasMore || hitPageBudget || active.length === SESSION_LIMIT,
285
+ fullSync: true,
159
286
  now: now(),
160
287
  });
161
- for (const session of nextSessions) {
288
+ for (const session of active) {
162
289
  if (!session?.id) continue;
163
290
  const key = `${task.runtimeKey}\n${task.directory}\n${session.id}`;
164
291
  if (queuedChildFlagKeys.has(key)) continue;
@@ -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,
@@ -89,6 +89,160 @@ describe('session index background sync runtime', () => {
89
89
  await expect(changed).resolves.toMatchObject({ revision: initialRevision + 1 });
90
90
  });
91
91
 
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
+
92
246
  it('v2 session.list has no incremental start — always fetches a full page', async () => {
93
247
  const service = createService([{
94
248
  directory: '/repo',
@@ -5,37 +5,82 @@ OpenCode `session.metadata`.
5
5
 
6
6
  ## Why
7
7
 
8
- OpenCode 2.x accepts `metadata` only at session create time. There is no
9
- `PATCH /session/:id` (or equivalent) to update it afterwards. Session goal
10
- progress — and later other Host state under the same namespace — therefore
11
- lives in this Host store and is folded back onto proxy session list/detail
12
- responses so clients still read `session.metadata`.
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.
13
24
 
14
25
  ## Layout
15
26
 
16
27
  | File | Role |
17
28
  |---|---|
18
- | `session-metadata-store.js` | Durable store: one JSON file per data dir, `{ [sessionID]: metadata }`, RFC 7386 merge patch, refuse-failed-read refuses write |
19
- | `routes.js` | `PUT /api/openchamber/sessions/:sessionId/metadata` + re-exports `createSessionMetadataStore` |
20
- | `*.test.js` | Store + route contracts |
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 |
21
36
 
22
37
  ## Contracts
23
38
 
24
39
  - Writes are JSON Merge Patch (RFC 7386): nested objects merge key-by-key; `null` deletes a key.
25
- - A failed file read is **not** empty success — the store refuses writes until a load succeeds, so unknown disk state is never overwritten with `{}`.
26
- - Entries are keyed by `sessionID` only (not directory).
27
- - Successful `PUT` may broadcast `openchamber:session-metadata` with the **full** merged metadata object.
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.
28
57
 
29
58
  ## Server wiring
30
59
 
31
- Wired from `packages/web/server/index.js`:
60
+ Wired from `packages/web/server/index.js` + `feature-routes-runtime.js`:
32
61
 
33
- 1. `createSessionMetadataStore({ dataDir: OPENCHAMBER_DATA_DIR })`
34
- 2. proxy `getStoredSessionMetadata: () => store.getAll()`
35
- 3. session-goal `readSessionMetadata` / `persistSessionGoal`
36
- 4. `registerSessionMetadataRoutes` — a successful PUT notifies the goal runtime with a synthetic `session.updated` so a UI create/resume arms the loop
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.
37
75
 
38
76
  Capability is open (`supported: true`). Manual UI metadata writes, session-goal
39
77
  routes, and scheduled-task goal create all persist `openchamber.goal` through
40
78
  this store. After first create/resume persist, the server notifies the goal
41
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.