@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
@@ -22,7 +22,9 @@ the index excludes them from every snapshot and clears prior summaries when a
22
22
  `metadata.openchamber.smallModel.purpose`, or
23
23
  `metadata.openchamber.llm.purpose`. Title prefixes are human labels and
24
24
  never participate in this filter. Metadata is the ownership/isolation signal;
25
- `time.archived` is archive state; `time.pinned` is OpenChamber-owned pin state
25
+ `time.archived` is archive state (Host authority via
26
+ `metadata.openchamber.archive.archivedAt` when present — see
27
+ `session-metadata/`); `time.pinned` is OpenChamber-owned pin state
26
28
  overlaid from the independent `session_pin` table (also mirrored onto in-window
27
29
  `session_summary.pinned_at` for older readers). Pins are not subject to the
28
30
  newest-20 summary bound: evicting or rebuilding a directory keeps pin
@@ -30,7 +32,9 @@ membership, and `setPinned` can target a session that is not currently in the
30
32
  index. Archive or `remove` still clears the pin. Live OpenCode upserts never
31
33
  overwrite pin membership. Titles are for recognition. The index does
32
34
  not cache system sessions, so cold-start and live upserts stay aligned with
33
- ordinary sidebar lists.
35
+ ordinary sidebar lists. System session creators persist isolation through the
36
+ Host metadata store, then `onSystemSessionPersisted` upserts the projected
37
+ session so a create-before-persist race cannot leave a visible summary.
34
38
  Pin and unpin use `POST` / `DELETE`
35
39
  `/api/openchamber/session-index/session/:id/pin`, write `session_pin`, then
36
40
  `publishChange()` so revision tips broadcast `openchamber:session-index-changed`.
@@ -49,19 +53,18 @@ events that remain outside the newest-20 bound follow the same no-op path.
49
53
 
50
54
  `sync-runtime.js` owns cold-start synchronization. The renderer submits all
51
55
  known project directories once to `POST /api/openchamber/session-index/sync`.
52
- The runtime processes them sequentially, applies `start=lastSyncedAt` for
53
- recent indexes, performs a full reconciliation after 24 hours, and commits each
54
- result to SQLite. A directory is incrementally eligible only when it already
55
- has at least one cached root summary; empty cached directories always request
56
- the full newest page, because a successful `[]` still stores `lastSyncedAt` as
57
- a worktree-topology hint and reusing that watermark as `start` would hide older
58
- historical roots until the next full pass. Request count stays one GET per
59
- directory (`limit=20`). It publishes an in-memory revision after every
60
- externally observable index or synchronization-state change. A successful empty
61
- directory refresh remains in the snapshot so another client can use it as a
62
- worktree-topology recovery hint. Stopping the runtime marks queued and in-flight
63
- directories as failed in the published progress snapshot so clients can retire
64
- their matching loading state.
56
+ The runtime processes them sequentially. OpenCode 2.x list is cursor-only (no
57
+ `start` incremental); each directory fetches active-only pages (`limit=20`)
58
+ after optional Host `projectSessions` overlay. Consecutive archived rows may
59
+ consume further pages within a small page budget so the newest-20 **active**
60
+ window can still fill; exhausting the budget before the window is full is an
61
+ explicit partial result (`hasMore` stays true). `replaceDirectory` /
62
+ event-ingest upsert keep active-only rows (archived clears the summary and pin).
63
+ It publishes an in-memory revision after every externally observable index or
64
+ synchronization-state change. A successful empty directory refresh remains in
65
+ the snapshot so another client can use it as a worktree-topology recovery hint.
66
+ Stopping the runtime marks queued and in-flight directories as failed in the
67
+ published progress snapshot so clients can retire their matching loading state.
65
68
 
66
69
  The renderer observes revisions through OpenChamber SSE tip events
67
70
  (`openchamber:session-index-changed`). Each tip carries the new revision and
@@ -1,58 +1,131 @@
1
+ import {
2
+ extractSessionInfoFromPayload,
3
+ isSessionLifecycleEventType,
4
+ normalizeSessionEventType,
5
+ resolveSessionDirectory,
6
+ } from '../session-metadata/session-projection.js';
7
+
1
8
  const withDirectory = (session, directory) => {
2
9
  if (!session || typeof session !== 'object') return null;
10
+ const resolved = resolveSessionDirectory(session);
11
+ if (resolved) return { ...session, directory: resolved };
3
12
  if (session.directory || session.project?.worktree || !directory || directory === 'global') return session;
4
13
  return { ...session, directory };
5
14
  };
6
15
 
7
- export const applySessionIndexEvent = (service, event, observedAt = Date.now()) => {
16
+ /**
17
+ * @param {object} service
18
+ * @param {object} event
19
+ * @param {number} [observedAt]
20
+ * @param {object} [options]
21
+ * @param {(session: object) => object | null | undefined} [options.projectSession]
22
+ * Host authority projection before index write. Returning `null` means Host
23
+ * not ready / unavailable — skip the index write and keep prior rows.
24
+ * @param {(sessionID: string) => void | Promise<void>} [options.onSessionDeleted]
25
+ * @param {() => boolean} [options.isHostReady]
26
+ * When false, session lifecycle upserts are skipped until metadata is ready.
27
+ */
28
+ export const applySessionIndexEvent = (service, event, observedAt = Date.now(), options = {}) => {
8
29
  if (!service || !event || typeof event !== 'object') return false;
9
30
  const payload = event.payload && typeof event.payload === 'object' ? event.payload : event;
10
31
  const directory = typeof event.directory === 'string' ? event.directory : '';
11
32
  const properties = payload.properties && typeof payload.properties === 'object' ? payload.properties : {};
33
+ const projectSession = typeof options.projectSession === 'function' ? options.projectSession : null;
34
+ const onSessionDeleted = typeof options.onSessionDeleted === 'function' ? options.onSessionDeleted : null;
35
+ const isHostReady = typeof options.isHostReady === 'function' ? options.isHostReady : null;
36
+
37
+ const baseType = normalizeSessionEventType(payload.type);
38
+
39
+ if (baseType === 'session.created' || baseType === 'session.updated') {
40
+ // Until Host metadata is ready, do not write session lifecycle into the
41
+ // index (would bake unprojected upstream archive). Transcript paths use
42
+ // message.* and stay open.
43
+ if (isHostReady && !isHostReady()) return false;
12
44
 
13
- if (payload.type === 'session.created' || payload.type === 'session.updated') {
14
- const session = withDirectory(properties.info, directory);
15
- return session ? service.upsertAndReportChange(session, observedAt, { preserveActivity: true }) : false;
45
+ let session = withDirectory(extractSessionInfoFromPayload(payload), directory);
46
+ if (!session) return false;
47
+
48
+ if (projectSession) {
49
+ try {
50
+ const projected = projectSession(session);
51
+ // null/undefined from projector = Host unavailable — keep old index data.
52
+ if (projected == null) return false;
53
+ session = projected;
54
+ } catch (error) {
55
+ console.warn('[session-index] projectSession failed; keeping prior index rows:', error?.message ?? error);
56
+ return false;
57
+ }
58
+ }
59
+ return service.upsertAndReportChange(session, observedAt, { preserveActivity: true });
16
60
  }
17
61
 
18
- if (payload.type === 'session.deleted') {
62
+ if (baseType === 'session.deleted') {
19
63
  const sessionID = typeof properties.sessionID === 'string'
20
64
  ? properties.sessionID
21
65
  : typeof properties.info?.id === 'string'
22
66
  ? properties.info.id
23
- : '';
24
- return sessionID ? service.remove(sessionID) : false;
67
+ : typeof payload.data?.sessionID === 'string'
68
+ ? payload.data.sessionID
69
+ : typeof payload.data?.info?.id === 'string'
70
+ ? payload.data.info.id
71
+ : '';
72
+ if (!sessionID) return false;
73
+ const removed = service.remove(sessionID);
74
+ if (onSessionDeleted) {
75
+ try {
76
+ const result = onSessionDeleted(sessionID);
77
+ if (result && typeof result.catch === 'function') {
78
+ result.catch((error) => {
79
+ console.warn('[session-index] onSessionDeleted failed (retryable):', error?.message ?? error);
80
+ });
81
+ }
82
+ } catch (error) {
83
+ console.warn('[session-index] onSessionDeleted failed (retryable):', error?.message ?? error);
84
+ }
85
+ }
86
+ return removed;
25
87
  }
26
88
 
27
- if (payload.type === 'message.updated') {
28
- const info = properties.info && typeof properties.info === 'object' ? properties.info : {};
89
+ if (baseType === 'message.updated' || payload.type === 'message.updated') {
90
+ const info = properties.info && typeof properties.info === 'object'
91
+ ? properties.info
92
+ : (payload.data?.info && typeof payload.data.info === 'object' ? payload.data.info : {});
29
93
  if (info.role !== 'user' || typeof info.sessionID !== 'string') return false;
30
94
  const activityAt = typeof info.time?.created === 'number' ? info.time.created : observedAt;
31
95
  return service.touchActivity(info.sessionID, activityAt);
32
96
  }
33
97
 
34
- if (payload.type === 'session.status') {
35
- const sessionID = typeof properties.sessionID === 'string' ? properties.sessionID : '';
98
+ if (baseType === 'session.status') {
99
+ const sessionID = typeof properties.sessionID === 'string'
100
+ ? properties.sessionID
101
+ : (typeof payload.data?.sessionID === 'string' ? payload.data.sessionID : '');
36
102
  const status = typeof properties.status?.type === 'string'
37
103
  ? properties.status.type
38
104
  : typeof properties.info?.type === 'string'
39
105
  ? properties.info.type
40
- : '';
106
+ : (typeof payload.data?.status?.type === 'string' ? payload.data.status.type : '');
41
107
  return sessionID && status ? service.updateStatus(sessionID, status, observedAt) : false;
42
108
  }
43
109
 
44
- if (payload.type === 'session.idle') {
45
- const sessionID = typeof properties.sessionID === 'string' ? properties.sessionID : '';
110
+ if (baseType === 'session.idle') {
111
+ const sessionID = typeof properties.sessionID === 'string'
112
+ ? properties.sessionID
113
+ : (typeof payload.data?.sessionID === 'string' ? payload.data.sessionID : '');
46
114
  if (!sessionID) return false;
47
115
  const activityChanged = service.touchActivity(sessionID, observedAt);
48
116
  const statusChanged = service.updateStatus(sessionID, 'idle', observedAt);
49
117
  return activityChanged || statusChanged;
50
118
  }
51
119
 
52
- if (payload.type === 'session.error') {
53
- const sessionID = typeof properties.sessionID === 'string' ? properties.sessionID : '';
120
+ if (baseType === 'session.error') {
121
+ const sessionID = typeof properties.sessionID === 'string'
122
+ ? properties.sessionID
123
+ : (typeof payload.data?.sessionID === 'string' ? payload.data.sessionID : '');
54
124
  return sessionID ? service.updateStatus(sessionID, 'idle', observedAt) : false;
55
125
  }
56
126
 
127
+ // Unknown lifecycle variants with version suffixes already normalized above.
128
+ if (isSessionLifecycleEventType(payload.type)) return false;
129
+
57
130
  return false;
58
131
  };
@@ -78,4 +78,77 @@ describe('session index event ingest', () => {
78
78
  expect(service.touchActivity).toHaveBeenCalledWith('ses_1', 400);
79
79
  expect(service.updateStatus).toHaveBeenCalledWith('ses_1', 'idle', 400);
80
80
  });
81
+
82
+ it('projects Host archive onto session upserts and cleans metadata on delete', () => {
83
+ const service = {
84
+ upsertAndReportChange: vi.fn(() => true),
85
+ remove: vi.fn(() => true),
86
+ };
87
+ const onSessionDeleted = vi.fn();
88
+ const projectSession = (session) => ({
89
+ ...session,
90
+ time: { ...session.time, archived: 77 },
91
+ });
92
+
93
+ applySessionIndexEvent(service, {
94
+ directory: '/repo',
95
+ payload: {
96
+ type: 'session.updated',
97
+ properties: { info: { id: 'ses_1', title: 'S', time: { created: 1, updated: 1 } } },
98
+ },
99
+ }, 100, { projectSession, isHostReady: () => true });
100
+
101
+ expect(service.upsertAndReportChange).toHaveBeenCalledWith(
102
+ expect.objectContaining({ id: 'ses_1', time: { created: 1, updated: 1, archived: 77 } }),
103
+ 100,
104
+ { preserveActivity: true },
105
+ );
106
+
107
+ applySessionIndexEvent(service, {
108
+ payload: { type: 'session.deleted', properties: { sessionID: 'ses_1' } },
109
+ }, 200, { onSessionDeleted });
110
+ expect(service.remove).toHaveBeenCalledWith('ses_1');
111
+ expect(onSessionDeleted).toHaveBeenCalledWith('ses_1');
112
+ });
113
+
114
+ it('supports v2 data.info and versioned type suffixes', () => {
115
+ const service = { upsertAndReportChange: vi.fn(() => true) };
116
+ applySessionIndexEvent(service, {
117
+ directory: '/repo',
118
+ payload: {
119
+ type: 'session.updated.v2',
120
+ data: { info: { id: 'ses_v2', title: 'V2', time: { created: 1, updated: 2 } } },
121
+ },
122
+ }, 50, { isHostReady: () => true });
123
+ expect(service.upsertAndReportChange).toHaveBeenCalledWith(
124
+ expect.objectContaining({ id: 'ses_v2', directory: '/repo' }),
125
+ 50,
126
+ { preserveActivity: true },
127
+ );
128
+ });
129
+
130
+ it('skips lifecycle index writes when Host is not ready (keeps prior rows)', () => {
131
+ const service = { upsertAndReportChange: vi.fn(() => true) };
132
+ expect(applySessionIndexEvent(service, {
133
+ payload: {
134
+ type: 'session.updated',
135
+ properties: { info: { id: 'ses_1', time: { created: 1, updated: 1 } } },
136
+ },
137
+ }, 1, { isHostReady: () => false })).toBe(false);
138
+ expect(service.upsertAndReportChange).not.toHaveBeenCalled();
139
+ });
140
+
141
+ it('skips index write when projectSession fails (not empty success)', () => {
142
+ const service = { upsertAndReportChange: vi.fn(() => true) };
143
+ expect(applySessionIndexEvent(service, {
144
+ payload: {
145
+ type: 'session.updated',
146
+ properties: { info: { id: 'ses_1', time: { created: 1, updated: 1 } } },
147
+ },
148
+ }, 1, {
149
+ isHostReady: () => true,
150
+ projectSession: () => { throw new Error('store down'); },
151
+ })).toBe(false);
152
+ expect(service.upsertAndReportChange).not.toHaveBeenCalled();
153
+ });
81
154
  });
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Apply committed Host archive authority onto a session-index snapshot / lookup.
3
+ * Used by production GET routes so SQLite rows never surface Host-archived
4
+ * sessions (or pins) after a crash between metadata commit and index apply.
5
+ */
6
+
7
+ import {
8
+ isSessionArchived,
9
+ projectSessionWithHostMetadata,
10
+ readHostArchivedAt,
11
+ } from '../session-metadata/session-projection.js';
12
+
13
+ const isPlainObject = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
14
+
15
+ /**
16
+ * @param {object | null | undefined} session summary-shaped session from index
17
+ * @param {Record<string, object> | null} storedById committed Host metadata map
18
+ */
19
+ export const projectIndexSessionWithHost = (session, storedById) => {
20
+ if (!session || typeof session !== 'object' || typeof session.id !== 'string') return session;
21
+ const host = storedById && isPlainObject(storedById[session.id]) ? storedById[session.id] : null;
22
+ // Index summaries already carry time.*; fold Host archive the same way as proxy.
23
+ return projectSessionWithHostMetadata(session, host);
24
+ };
25
+
26
+ /**
27
+ * Filter a full index snapshot through Host archive authority.
28
+ * Archived sessions drop from directory lists; pins for Host-archived ids drop.
29
+ *
30
+ * @param {object} snapshot `{ directories, pinnedSessionIds, ... }`
31
+ * @param {Record<string, object>} storedById
32
+ */
33
+ export const applyHostArchiveToIndexSnapshot = (snapshot, storedById) => {
34
+ if (!snapshot || typeof snapshot !== 'object') return snapshot;
35
+ const stored = storedById && typeof storedById === 'object' ? storedById : {};
36
+
37
+ const directories = Array.isArray(snapshot.directories)
38
+ ? snapshot.directories.map((directory) => {
39
+ if (!directory || typeof directory !== 'object') return directory;
40
+ const sessions = Array.isArray(directory.sessions)
41
+ ? directory.sessions
42
+ .map((session) => projectIndexSessionWithHost(session, stored))
43
+ .filter((session) => session && !isSessionArchived(session))
44
+ : [];
45
+ return { ...directory, sessions };
46
+ })
47
+ : snapshot.directories;
48
+
49
+ const pinnedSessionIds = Array.isArray(snapshot.pinnedSessionIds)
50
+ ? snapshot.pinnedSessionIds.filter((id) => {
51
+ if (typeof id !== 'string' || !id) return false;
52
+ const hostArchived = readHostArchivedAt(stored[id]);
53
+ // positive Host archive hides pin; 0 / missing keep pin membership.
54
+ return !(typeof hostArchived === 'number' && hostArchived > 0);
55
+ })
56
+ : snapshot.pinnedSessionIds;
57
+
58
+ return {
59
+ ...snapshot,
60
+ directories,
61
+ pinnedSessionIds,
62
+ };
63
+ };
64
+
65
+ /**
66
+ * Lookup hit: if Host-archived, treat as not found for active deep-links.
67
+ * @returns {{ session: object } | { hidden: true } | null}
68
+ */
69
+ export const applyHostArchiveToLookupHit = (hit, storedById) => {
70
+ if (!hit || typeof hit !== 'object') return null;
71
+ const projected = projectIndexSessionWithHost(
72
+ {
73
+ ...hit,
74
+ time: {
75
+ created: hit.createdAt,
76
+ updated: hit.updatedAt,
77
+ ...(hit.archivedAt ? { archived: hit.archivedAt } : {}),
78
+ },
79
+ },
80
+ storedById,
81
+ );
82
+ if (isSessionArchived(projected)) return { hidden: true };
83
+ return {
84
+ session: {
85
+ ...hit,
86
+ // Expose projected archive stamp for clients that read time from lookup.
87
+ archivedAt: projected?.time?.archived ?? 0,
88
+ time: projected?.time,
89
+ metadata: projected?.metadata,
90
+ },
91
+ };
92
+ };
@@ -0,0 +1,201 @@
1
+ import fs from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import { afterEach, describe, expect, it, vi } from 'vitest';
5
+
6
+ import { createSessionMetadataStore } from '../session-metadata/session-metadata-store.js';
7
+ import { createSessionArchiveService } from '../session-metadata/session-archive.js';
8
+ import { createSessionIndexService } from './service.js';
9
+ import { registerSessionIndexRoutes } from './routes.js';
10
+
11
+ const tempDirs = [];
12
+
13
+ afterEach(() => {
14
+ while (tempDirs.length > 0) {
15
+ fs.rmSync(tempDirs.pop(), { recursive: true, force: true });
16
+ }
17
+ vi.restoreAllMocks();
18
+ });
19
+
20
+ const makeDir = () => {
21
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'oc-index-host-'));
22
+ tempDirs.push(dir);
23
+ return dir;
24
+ };
25
+
26
+ const registry = () => {
27
+ const routes = new Map();
28
+ return {
29
+ app: {
30
+ get: (pathValue, handler) => routes.set(`GET ${pathValue}`, handler),
31
+ put: (pathValue, handler) => routes.set(`PUT ${pathValue}`, handler),
32
+ post: (pathValue, handler) => routes.set(`POST ${pathValue}`, handler),
33
+ delete: (pathValue, handler) => routes.set(`DELETE ${pathValue}`, handler),
34
+ },
35
+ route: (method, pathValue) => routes.get(`${method} ${pathValue}`),
36
+ };
37
+ };
38
+
39
+ const response = () => ({
40
+ statusCode: 200,
41
+ body: undefined,
42
+ status(code) { this.statusCode = code; return this; },
43
+ json(body) { this.body = body; return this; },
44
+ end() { return this; },
45
+ });
46
+
47
+ describe('session-index routes + Host archive (production path)', () => {
48
+ it('snapshot/lookup filter Host-archived rows and pins; 503 when metadata unavailable', async () => {
49
+ const dataDir = makeDir();
50
+ const store = createSessionMetadataStore({ dataDir });
51
+ const index = createSessionIndexService({
52
+ dbPath: path.join(dataDir, 'index.sqlite'),
53
+ getRuntimeConfig: () => ({ apiBaseUrl: 'http://runtime-test' }),
54
+ });
55
+ index.replaceDirectory({
56
+ directory: '/repo',
57
+ sessions: [
58
+ { id: 'ses_live', title: 'Live', directory: '/repo', time: { created: 1, updated: 10 } },
59
+ { id: 'ses_arch', title: 'Arch', directory: '/repo', time: { created: 1, updated: 9 } },
60
+ ],
61
+ cursor: null,
62
+ hasMore: false,
63
+ });
64
+ index.setPinned('ses_arch', 50);
65
+ index.setPinned('ses_live', 51);
66
+
67
+ await store.setSessionMetadata('ses_arch', {
68
+ openchamber: { archive: { archivedAt: 99 } },
69
+ }, { allowArchive: true });
70
+
71
+ const { app, route } = registry();
72
+ registerSessionIndexRoutes(app, {
73
+ sessionIndexService: index,
74
+ getCommittedHostMetadata: () => store.getAll(),
75
+ });
76
+
77
+ const snapRes = response();
78
+ await route('GET', '/api/openchamber/session-index')({}, snapRes);
79
+ expect(snapRes.statusCode).toBe(200);
80
+ expect(snapRes.body.directories[0].sessions.map((s) => s.id)).toEqual(['ses_live']);
81
+ expect(snapRes.body.pinnedSessionIds).toEqual(['ses_live']);
82
+
83
+ const lookupArch = response();
84
+ await route('GET', '/api/openchamber/session-index/session/:sessionId')(
85
+ { params: { sessionId: 'ses_arch' } },
86
+ lookupArch,
87
+ );
88
+ expect(lookupArch.statusCode).toBe(404);
89
+
90
+ const lookupLive = response();
91
+ await route('GET', '/api/openchamber/session-index/session/:sessionId')(
92
+ { params: { sessionId: 'ses_live' } },
93
+ lookupLive,
94
+ );
95
+ expect(lookupLive.statusCode).toBe(200);
96
+ expect(lookupLive.body.session.id).toBe('ses_live');
97
+
98
+ // Metadata unavailable → 503 retryable (production gate).
99
+ const { app: app2, route: route2 } = registry();
100
+ registerSessionIndexRoutes(app2, {
101
+ sessionIndexService: index,
102
+ getCommittedHostMetadata: async () => {
103
+ throw new Error('session metadata is unavailable: EACCES');
104
+ },
105
+ });
106
+ const failRes = response();
107
+ await route2('GET', '/api/openchamber/session-index')({}, failRes);
108
+ expect(failRes.statusCode).toBe(503);
109
+ expect(failRes.body).toMatchObject({ retryable: true, code: 'session_metadata_unavailable' });
110
+
111
+ index.close();
112
+ });
113
+
114
+ it('index write failure after archive schedules repair; rebuild re-projects from store', async () => {
115
+ const dataDir = makeDir();
116
+ const store = createSessionMetadataStore({ dataDir });
117
+ const timers = [];
118
+ const setTimer = (fn, ms) => {
119
+ const handle = { fn, ms, cleared: false };
120
+ timers.push(handle);
121
+ return handle;
122
+ };
123
+ const clearTimer = (handle) => { if (handle) handle.cleared = true; };
124
+
125
+ let upsertFails = true;
126
+ const upserts = [];
127
+ const archive = createSessionArchiveService({
128
+ sessionMetadataStore: store,
129
+ fetchUpstreamSession: async ({ sessionID }) => ({
130
+ id: sessionID,
131
+ directory: '/repo',
132
+ title: 'S',
133
+ time: { created: 1, updated: 2 },
134
+ }),
135
+ sessionIndexService: {
136
+ upsertAndReportChange: (session) => {
137
+ upserts.push(session);
138
+ if (upsertFails) throw new Error('sqlite locked');
139
+ return true;
140
+ },
141
+ },
142
+ setTimer,
143
+ clearTimer,
144
+ now: () => 1_000,
145
+ });
146
+
147
+ const result = await archive.setArchive({ sessionID: 'ses_1', archivedAt: 77 });
148
+ expect(result.index).toMatchObject({ ok: false, retryable: true });
149
+ await expect(store.get('ses_1')).resolves.toMatchObject({
150
+ openchamber: { archive: { archivedAt: 77 } },
151
+ });
152
+ expect(archive.getIndexRepairStatus('ses_1')).toMatchObject({ pending: true, retryable: true });
153
+ expect(timers).toHaveLength(1);
154
+ expect(timers[0].cleared).toBe(false);
155
+
156
+ // Repair run: re-project from committed store (not stale blob).
157
+ upsertFails = false;
158
+ await timers[0].fn();
159
+ expect(upserts.at(-1)?.time?.archived).toBe(77);
160
+ expect(archive.getIndexRepairStatus('ses_1')).toMatchObject({ ok: true, pending: false });
161
+
162
+ // stop() clears any remaining scheduled timers (none left after success).
163
+ archive.stop();
164
+ expect(archive.getIndexRepairStatus('ses_1').pending).toBe(false);
165
+ });
166
+
167
+ it('restart: fresh store+index routes hide Host-archived without pending repair state', async () => {
168
+ const dataDir = makeDir();
169
+ const store1 = createSessionMetadataStore({ dataDir });
170
+ await store1.setSessionMetadata('ses_x', {
171
+ openchamber: { archive: { archivedAt: 5 } },
172
+ }, { allowArchive: true });
173
+
174
+ // Simulate crash after metadata commit: index still has the row.
175
+ const index = createSessionIndexService({
176
+ dbPath: path.join(dataDir, 'index.sqlite'),
177
+ getRuntimeConfig: () => ({ apiBaseUrl: 'http://runtime-restart' }),
178
+ });
179
+ index.replaceDirectory({
180
+ directory: '/repo',
181
+ sessions: [
182
+ { id: 'ses_x', title: 'X', directory: '/repo', time: { created: 1, updated: 2 } },
183
+ { id: 'ses_y', title: 'Y', directory: '/repo', time: { created: 1, updated: 3 } },
184
+ ],
185
+ cursor: null,
186
+ hasMore: false,
187
+ });
188
+
189
+ // Process restart: new store loads committed archive.
190
+ const store2 = createSessionMetadataStore({ dataDir });
191
+ const { app, route } = registry();
192
+ registerSessionIndexRoutes(app, {
193
+ sessionIndexService: index,
194
+ getCommittedHostMetadata: () => store2.getAll(),
195
+ });
196
+ const res = response();
197
+ await route('GET', '/api/openchamber/session-index')({}, res);
198
+ expect(res.body.directories[0].sessions.map((s) => s.id)).toEqual(['ses_y']);
199
+ index.close();
200
+ });
201
+ });
@@ -1,12 +1,55 @@
1
+ import {
2
+ applyHostArchiveToIndexSnapshot,
3
+ applyHostArchiveToLookupHit,
4
+ } from './host-authority-snapshot.js';
5
+
1
6
  const unsupported = (res) => res.status(501).json({ error: 'Session index is unavailable for this runtime' });
2
7
 
3
- export const registerSessionIndexRoutes = (app, { sessionIndexService, sessionIndexSyncRuntime }) => {
4
- app.get('/api/openchamber/session-index', (_req, res) => {
8
+ /**
9
+ * @param {import('express').Express} app
10
+ * @param {object} deps
11
+ * @param {object | null} deps.sessionIndexService
12
+ * @param {object | null} [deps.sessionIndexSyncRuntime]
13
+ * @param {() => Promise<Record<string, object>> | Record<string, object> | null} [deps.getCommittedHostMetadata]
14
+ * Committed Host metadata map. Throws / returns null on unavailable → 503.
15
+ */
16
+ export const registerSessionIndexRoutes = (app, {
17
+ sessionIndexService,
18
+ sessionIndexSyncRuntime,
19
+ getCommittedHostMetadata = null,
20
+ } = {}) => {
21
+ const hostStoreWired = typeof getCommittedHostMetadata === 'function';
22
+
23
+ const readHostMap = async () => {
24
+ if (!hostStoreWired) return { status: 'unconfigured', stored: {} };
25
+ try {
26
+ const stored = await getCommittedHostMetadata();
27
+ if (stored == null || typeof stored !== 'object') {
28
+ return { status: 'unavailable', error: 'session metadata is unavailable' };
29
+ }
30
+ return { status: 'ok', stored };
31
+ } catch (error) {
32
+ const message = error instanceof Error ? error.message : 'session metadata is unavailable';
33
+ return { status: 'unavailable', error: message };
34
+ }
35
+ };
36
+
37
+ const metadataUnavailable = (res, error) => res.status(503).json({
38
+ error: error || 'session metadata is unavailable',
39
+ code: 'session_metadata_unavailable',
40
+ retryable: true,
41
+ });
42
+
43
+ app.get('/api/openchamber/session-index', async (_req, res) => {
5
44
  if (!sessionIndexService) return unsupported(res);
6
- res.json({
7
- available: true,
8
- ...(sessionIndexSyncRuntime?.snapshot() ?? sessionIndexService.snapshot()),
9
- });
45
+ const base = sessionIndexSyncRuntime?.snapshot() ?? sessionIndexService.snapshot();
46
+ if (!hostStoreWired) {
47
+ return res.json({ available: true, ...base });
48
+ }
49
+ const host = await readHostMap();
50
+ if (host.status === 'unavailable') return metadataUnavailable(res, host.error);
51
+ const filtered = applyHostArchiveToIndexSnapshot(base, host.stored);
52
+ return res.json({ available: true, ...filtered });
10
53
  });
11
54
 
12
55
  app.post('/api/openchamber/session-index/sync', (req, res) => {
@@ -47,7 +90,7 @@ export const registerSessionIndexRoutes = (app, { sessionIndexService, sessionIn
47
90
  });
48
91
 
49
92
  // Deep-link resolution: GET by session id without requiring a directory.
50
- app.get('/api/openchamber/session-index/session/:sessionId', (req, res) => {
93
+ app.get('/api/openchamber/session-index/session/:sessionId', async (req, res) => {
51
94
  if (!sessionIndexService) return unsupported(res);
52
95
  if (typeof sessionIndexService.findBySessionId !== 'function') {
53
96
  return res.status(501).json({ error: 'Session lookup is unavailable' });
@@ -56,11 +99,21 @@ export const registerSessionIndexRoutes = (app, { sessionIndexService, sessionIn
56
99
  if (typeof sessionId !== 'string' || !sessionId.trim()) {
57
100
  return res.status(400).json({ error: 'sessionId is required' });
58
101
  }
59
- const hit = sessionIndexService.findBySessionId(sessionId.trim());
102
+ const id = sessionId.trim();
103
+ const hit = sessionIndexService.findBySessionId(id);
60
104
  if (!hit) {
61
- return res.status(404).json({ error: 'session_not_found', sessionId: sessionId.trim() });
105
+ return res.status(404).json({ error: 'session_not_found', sessionId: id });
106
+ }
107
+ if (!hostStoreWired) {
108
+ return res.json({ available: true, session: hit });
109
+ }
110
+ const host = await readHostMap();
111
+ if (host.status === 'unavailable') return metadataUnavailable(res, host.error);
112
+ const projected = applyHostArchiveToLookupHit(hit, host.stored);
113
+ if (!projected || projected.hidden) {
114
+ return res.status(404).json({ error: 'session_not_found', sessionId: id });
62
115
  }
63
- res.json({ available: true, session: hit });
116
+ return res.json({ available: true, session: projected.session });
64
117
  });
65
118
 
66
119
  app.post('/api/openchamber/session-index/session/:id/pin', (req, res) => {