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

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 (296) 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-pLaqWWmB.js} +1 -1
  7. package/dist/assets/AssistantView-C4GkABnI.js +3 -0
  8. package/dist/assets/{DiagramView-xuSJTnv7.js → DiagramView-ByYLIfWo.js} +1 -1
  9. package/dist/assets/{FadeInOnReveal-Ov0D96d0.js → FadeInOnReveal-MuJAzJq_.js} +1 -1
  10. package/dist/assets/Icon-Dz2kO9RN.js +1 -0
  11. package/dist/assets/MarkdownRenderer-CPU6kSHd.js +2 -0
  12. package/dist/assets/{MarkdownRendererImpl-SKi4oG0c.js → MarkdownRendererImpl-2hw5cdqQ.js} +3 -3
  13. package/dist/assets/MarkstreamRendererImpl-DXZQkLTa.js +3 -0
  14. package/dist/assets/{MobileDetailNavigation-Bnx59U_J.js → MobileDetailNavigation-C9BzieQY.js} +1 -1
  15. package/dist/assets/{MobileShareBridge-BQlP4J-F.js → MobileShareBridge-2_iOfzqO.js} +2 -2
  16. package/dist/assets/MobileSurface-DNVA7K_1.js +2 -0
  17. package/dist/assets/{MultiRunWindow-ChZGob0v.js → MultiRunWindow-QA6pMrJ9.js} +1 -1
  18. package/dist/assets/{OnboardingScreen-DtQJxZG-.js → OnboardingScreen-XWkr7MjT.js} +1 -1
  19. package/dist/assets/OverlayScrollbar-DCWyGseO.js +2 -0
  20. package/dist/assets/SettingsView-dWuu0VIb.js +25 -0
  21. package/dist/assets/{SettingsWindow-DfnsFa8y.js → SettingsWindow-CetgiJMX.js} +1 -1
  22. package/dist/assets/TerminalView-BuXuo7gm.js +8 -0
  23. package/dist/assets/{ToolOutputDialog-BejmjrRk.js → ToolOutputDialog-DNSSOaCt.js} +2 -2
  24. package/dist/assets/appThemeRegistry-Cb7qFBsc.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-Doq9FI1I.js} +1 -1
  27. package/dist/assets/{collapsible-BXRd5_Hr.js → collapsible-CYUeu3_N.js} +1 -1
  28. package/dist/assets/{context-menu-jGtH6fR9.js → context-menu-C8kOMVtC.js} +1 -1
  29. package/dist/assets/{desktopHosts-B1ixEylr.js → desktopHosts-DbxnIQdS.js} +1 -1
  30. package/dist/assets/{desktopRelayRestore-4JmFUw8r.js → desktopRelayRestore-dvxF4V2p.js} +1 -1
  31. package/dist/assets/{diagram-DQf7Y_uR.js → diagram-B1p9Yn1h.js} +1 -1
  32. package/dist/assets/{dialog-bfbvR4VH.js → dialog-D08-Lg-e.js} +1 -1
  33. package/dist/assets/es-Dsmc0A_3.js +15 -0
  34. package/dist/assets/fileMentionTouchSelection-BCAKb-NS.js +3 -0
  35. package/dist/assets/{fileQueries-DQc8EJUf.js → fileQueries-X773xxvX.js} +1 -1
  36. package/dist/assets/fr-C_ZlZDI2.js +13 -0
  37. package/dist/assets/gitApi-3VBj3fbR.js +10 -0
  38. package/dist/assets/{imageSource-BlAJJUEI.js → imageSource-BMztRu0c.js} +1 -1
  39. package/dist/assets/{input-8r6F_Fza.js → input-DZZuQ1IL.js} +1 -1
  40. package/dist/assets/insertionBoundaries-BMuCcvPy.js +1 -0
  41. package/dist/assets/ja-BqqTaWTi.js +15 -0
  42. package/dist/assets/ko-DP2XPM37.js +15 -0
  43. package/dist/assets/main-COEILjRF.js +169 -0
  44. package/dist/assets/main-D2BzpnUt.css +1 -0
  45. package/dist/assets/{main-CCE_4y5F.js → main-DsUewWPt.js} +2 -2
  46. package/dist/assets/markdown-worker-bS5OgGO3.js +2 -0
  47. package/dist/assets/{markdownHeightCache-1Qo6y6IJ.js → markdownHeightCache-DBgXGze8.js} +1 -1
  48. package/dist/assets/markdownTheme-DM5h1i18.js +1 -0
  49. package/dist/assets/mcpQueries-B05QR67V.js +1 -0
  50. package/dist/assets/miniChat-D7IPMm-k.js +2 -0
  51. package/dist/assets/mobile-BnCoDxnv.js +2 -0
  52. package/dist/assets/multirun-BDmFjX9N.js +4 -0
  53. package/dist/assets/persistence-DznVIKwz.js +3 -0
  54. package/dist/assets/pl-BCf5uLlK.js +15 -0
  55. package/dist/assets/platform-DSTtx-DI.js +6 -0
  56. package/dist/assets/projectMeta-C5gcAd5f.js +3 -0
  57. package/dist/assets/pt-BR-W09Q2pvn.js +15 -0
  58. package/dist/assets/{relay-image-stream-DM7atqJs.js → relay-image-stream-DrdVj99c.js} +1 -1
  59. package/dist/assets/renderElectronMiniChatApp-DsAaWCss.js +2 -0
  60. package/dist/assets/renderMobileApp-DOaoohlp.js +28 -0
  61. package/dist/assets/runtime-fetch-Fs9ZV2V8.js +1 -0
  62. package/dist/assets/{runtimeConfig-BMvqa5we.js → runtimeConfig-CzPK1uMj.js} +1 -1
  63. package/dist/assets/runtimeEndpointReset-DKjVJCdt.js +149 -0
  64. package/dist/assets/sessionChangeSummary-CktSWasa.js +2 -0
  65. package/dist/assets/{sessionLookup-gh-hVRIc.js → sessionLookup-CDA1aAcI.js} +1 -1
  66. package/dist/assets/{sortable-tabs-strip-CudoSgV4.js → sortable-tabs-strip-Q3bb6MVj.js} +1 -1
  67. package/dist/assets/src-lS1keDVV.css +2 -0
  68. package/dist/assets/store-jaP79mqI.js +15 -0
  69. package/dist/assets/{terminalApi-DiF6JmBP.js → terminalApi-Da_DO_Uh.js} +1 -1
  70. package/dist/assets/textarea--CYehp07.js +1 -0
  71. package/dist/assets/{tooltip-CQvpK-UV.js → tooltip-CGLzBwEK.js} +1 -1
  72. package/dist/assets/uk-BgFzz8m2.js +15 -0
  73. package/dist/assets/{url-_z7evzdK.js → url-Byz3kd29.js} +1 -1
  74. package/dist/assets/{useAppFontEffects-Cpmpo7Wb.js → useAppFontEffects-2zzPMQF2.js} +64 -45
  75. package/dist/assets/{useAssistantUIStore-CMutVh9B.js → useAssistantUIStore-Bz4tRflF.js} +1 -1
  76. package/dist/assets/useDesktopWindowControlsLayout-B-2YooUd.js +1 -0
  77. package/dist/assets/useEffectiveDirectory-K3U4eL5F.js +1 -0
  78. package/dist/assets/{useFontPreferences-DcbXQOkc.js → useFontPreferences-D9OIZs1p.js} +1 -1
  79. package/dist/assets/useMobileNavigationStore-B0zSdCwP.js +414 -0
  80. package/dist/assets/useSessionAutoCleanup-BCXHAcbX.js +2 -0
  81. package/dist/assets/useUIStore-syVmbuDA.js +1 -0
  82. package/dist/assets/{useWorkerHighlightedLines-Bqn4AiBL.js → useWorkerHighlightedLines-CsoMQNik.js} +2 -2
  83. package/dist/assets/vendor-opencode-client-C12gfjag.js +12 -0
  84. package/dist/assets/{vendor-reactuses-core-BnW6GxJt.js → vendor-reactuses-core-Dq5ocTF8.js} +1 -1
  85. package/dist/assets/zh-CN-BhqSLgUY.js +15 -0
  86. package/dist/assets/zh-TW-BsaBgY-p.js +15 -0
  87. package/dist/index.html +12 -12
  88. package/dist/mini-chat.html +12 -12
  89. package/dist/mobile.html +12 -12
  90. package/package.json +3 -2
  91. package/server/index.js +359 -63
  92. package/server/lib/assistants/DOCUMENTATION.md +14 -9
  93. package/server/lib/assistants/contracts.js +11 -1
  94. package/server/lib/assistants/projection-history.test.js +265 -18
  95. package/server/lib/assistants/routes.js +6 -0
  96. package/server/lib/assistants/service.js +417 -231
  97. package/server/lib/assistants/service.test.js +392 -148
  98. package/server/lib/browser-provider/DOCUMENTATION.md +141 -0
  99. package/server/lib/browser-provider/contract.js +312 -0
  100. package/server/lib/browser-provider/host.js +421 -0
  101. package/server/lib/browser-provider/host.test.js +304 -0
  102. package/server/lib/browser-provider/routes.js +122 -0
  103. package/server/lib/browser-provider/routes.test.js +120 -0
  104. package/server/lib/browser-provider/selection-store.js +43 -0
  105. package/server/lib/browser-provider/selection-store.test.js +35 -0
  106. package/server/lib/conversations/DOCUMENTATION.md +1 -1
  107. package/server/lib/conversations/service.js +1 -0
  108. package/server/lib/conversations/service.test.js +16 -0
  109. package/server/lib/conversations/validation.js +11 -1
  110. package/server/lib/event-stream/DOCUMENTATION.md +3 -1
  111. package/server/lib/event-stream/directory-ws-bridge.js +13 -2
  112. package/server/lib/event-stream/global-hub.js +49 -4
  113. package/server/lib/event-stream/reasoning-projection.js +16 -1
  114. package/server/lib/event-stream/reasoning-projection.test.js +15 -0
  115. package/server/lib/event-stream/runtime.js +57 -20
  116. package/server/lib/event-stream/runtime.test.js +112 -0
  117. package/server/lib/llm/DOCUMENTATION.md +33 -9
  118. package/server/lib/llm/catalog.js +1 -1
  119. package/server/lib/llm/completions.js +5 -1
  120. package/server/lib/llm/completions.test.js +3 -3
  121. package/server/lib/llm/generate.js +237 -145
  122. package/server/lib/llm/generate.test.js +201 -46
  123. package/server/lib/llm/routes.js +2 -0
  124. package/server/lib/message-queue/runtime.js +15 -2
  125. package/server/lib/notifications/DOCUMENTATION.md +3 -1
  126. package/server/lib/notifications/runtime.js +133 -31
  127. package/server/lib/notifications/runtime.test.js +103 -0
  128. package/server/lib/notifications/template-runtime.js +130 -38
  129. package/server/lib/notifications/template-runtime.test.js +96 -0
  130. package/server/lib/opencode/DOCUMENTATION.md +89 -17
  131. package/server/lib/opencode/agent-document.d.ts +28 -0
  132. package/server/lib/opencode/agent-document.js +280 -0
  133. package/server/lib/opencode/agent-document.test.js +137 -0
  134. package/server/lib/opencode/agents.js +96 -0
  135. package/server/lib/opencode/auth-state-runtime.js +5 -0
  136. package/server/lib/opencode/bootstrap-runtime.js +2 -0
  137. package/server/lib/opencode/config-entity-routes.js +42 -99
  138. package/server/lib/opencode/config-entity-routes.test.js +107 -0
  139. package/server/lib/opencode/config-hot-reload.integration.test.js +105 -0
  140. package/server/lib/opencode/ensure-cli.d.ts +8 -0
  141. package/server/lib/opencode/ensure-cli.js +1 -2
  142. package/server/lib/opencode/env-runtime.js +40 -26
  143. package/server/lib/opencode/env-runtime.test.js +10 -17
  144. package/server/lib/opencode/feature-routes-runtime.js +53 -4
  145. package/server/lib/opencode/feature-routes-runtime.test.js +25 -0
  146. package/server/lib/opencode/http-server-connections.js +98 -0
  147. package/server/lib/opencode/http-server-connections.test.js +321 -0
  148. package/server/lib/opencode/lifecycle.js +349 -39
  149. package/server/lib/opencode/lifecycle.test.js +322 -0
  150. package/server/lib/opencode/managed-capabilities-runtime.js +15 -2
  151. package/server/lib/opencode/managed-capabilities-runtime.test.js +1 -0
  152. package/server/lib/opencode/openchamber-routes.js +7 -1
  153. package/server/lib/opencode/opencode2-pin.js +4 -4
  154. package/server/lib/opencode/opencode2-pin.test.js +1 -0
  155. package/server/lib/opencode/opencode2-sidecar.e2e.test.js +127 -4
  156. package/server/lib/opencode/owned-runtime-upgrade.js +431 -0
  157. package/server/lib/opencode/owned-runtime-upgrade.test.js +160 -0
  158. package/server/lib/opencode/plugin-routes.js +3 -18
  159. package/server/lib/opencode/plugin-routes.test.js +9 -9
  160. package/server/lib/opencode/provider-catalog.js +58 -12
  161. package/server/lib/opencode/provider-catalog.test.js +48 -0
  162. package/server/lib/opencode/proxy.js +254 -46
  163. package/server/lib/opencode/proxy.test.js +238 -5
  164. package/server/lib/opencode/routes.js +326 -62
  165. package/server/lib/opencode/routes.test.js +278 -12
  166. package/server/lib/opencode/runtime-contract.js +401 -0
  167. package/server/lib/opencode/runtime-contract.test.js +214 -0
  168. package/server/lib/opencode/server-opencode-fetch.js +242 -0
  169. package/server/lib/opencode/server-opencode-fetch.test.js +130 -0
  170. package/server/lib/opencode/server-utils-runtime.js +33 -0
  171. package/server/lib/opencode/shared-service.integration.test.js +66 -0
  172. package/server/lib/opencode/shared-service.js +147 -0
  173. package/server/lib/opencode/shared-service.test.js +93 -0
  174. package/server/lib/opencode/shared.js +3 -3
  175. package/server/lib/opencode/shutdown-runtime.js +15 -6
  176. package/server/lib/opencode/shutdown-runtime.test.js +49 -1
  177. package/server/lib/opencode/skill-routes.js +20 -33
  178. package/server/lib/opencode/skill-routes.test.js +35 -2
  179. package/server/lib/opencode/skills.test.js +15 -0
  180. package/server/lib/opencode/startup-pipeline-runtime.js +4 -0
  181. package/server/lib/opencode/static-routes-runtime.js +3 -1
  182. package/server/lib/opencode/static-routes-runtime.test.js +33 -0
  183. package/server/lib/opencode/upgrade-screen.d.ts +30 -0
  184. package/server/lib/opencode/upgrade-screen.js +299 -0
  185. package/server/lib/opencode/upgrade-screen.test.js +196 -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 +73 -18
  211. package/server/lib/session-metadata/opencode-session-record.d.ts +57 -0
  212. package/server/lib/session-metadata/opencode-session-record.js +316 -0
  213. package/server/lib/session-metadata/opencode-session-record.test.js +242 -0
  214. package/server/lib/session-metadata/routes.js +112 -9
  215. package/server/lib/session-metadata/routes.test.js +47 -2
  216. package/server/lib/session-metadata/session-archive.d.ts +73 -0
  217. package/server/lib/session-metadata/session-archive.js +654 -0
  218. package/server/lib/session-metadata/session-archive.test.js +359 -0
  219. package/server/lib/session-metadata/session-metadata-store.d.ts +38 -0
  220. package/server/lib/session-metadata/session-metadata-store.js +270 -83
  221. package/server/lib/session-metadata/session-metadata-store.test.js +205 -10
  222. package/server/lib/session-metadata/session-projection.d.ts +41 -0
  223. package/server/lib/session-metadata/session-projection.js +279 -0
  224. package/server/lib/session-metadata/session-projection.test.js +64 -0
  225. package/server/lib/session-metadata/system-session.js +55 -0
  226. package/server/lib/session-metadata/system-session.test.js +55 -0
  227. package/server/lib/session-title/DOCUMENTATION.md +40 -7
  228. package/server/lib/session-title/native-runtime.test.js +167 -0
  229. package/server/lib/session-title/runtime.js +69 -44
  230. package/server/lib/session-title/session-access.js +46 -0
  231. package/server/lib/session-title/session-access.test.js +91 -0
  232. package/server/lib/session-turn-pages/DOCUMENTATION.md +9 -8
  233. package/server/lib/session-turn-pages/routes.js +17 -102
  234. package/server/lib/session-turn-pages/routes.test.js +281 -0
  235. package/server/lib/session-turn-pages/session-message-projection.d.ts +13 -0
  236. package/server/lib/session-turn-pages/session-message-projection.js +281 -0
  237. package/server/lib/small-model/DOCUMENTATION.md +101 -147
  238. package/server/lib/small-model/catalog.js +31 -51
  239. package/server/lib/small-model/catalog.test.js +65 -10
  240. package/server/lib/small-model/custom-api.js +68 -0
  241. package/server/lib/small-model/custom-api.test.js +64 -0
  242. package/server/lib/small-model/index.js +124 -240
  243. package/server/lib/small-model/resolve.js +35 -120
  244. package/server/lib/small-model/resolve.test.js +20 -110
  245. package/server/lib/small-model/routes.js +1 -3
  246. package/server/lib/small-model/summary-settings.test.js +157 -253
  247. package/server/opencode-proxy.test.js +2 -0
  248. package/dist/assets/AssistantView-BTCkm8yk.js +0 -3
  249. package/dist/assets/Icon-DD-BuMfZ.js +0 -1
  250. package/dist/assets/MarkdownRenderer-bc_p1H7l.js +0 -2
  251. package/dist/assets/MarkstreamRendererImpl-CnkodGJW.js +0 -3
  252. package/dist/assets/MobileSurface-BXLeiZ4d.js +0 -2
  253. package/dist/assets/OverlayScrollbar-pMwxaCca.js +0 -2
  254. package/dist/assets/SettingsView-B5I0KDTZ.js +0 -25
  255. package/dist/assets/TerminalView-Dh9MWCpS.js +0 -8
  256. package/dist/assets/appThemeRegistry-COGfxTFV.js +0 -1
  257. package/dist/assets/es-c52LTHHe.js +0 -15
  258. package/dist/assets/fileMentionTouchSelection-BvrNCroG.js +0 -4
  259. package/dist/assets/fr-PisFbUE8.js +0 -13
  260. package/dist/assets/gitApi-ButwzQ0N.js +0 -9
  261. package/dist/assets/insertionBoundaries-DGs7S5zv.js +0 -1
  262. package/dist/assets/ja-C8Ikun3Z.js +0 -15
  263. package/dist/assets/ko-ClFys5o2.js +0 -15
  264. package/dist/assets/main-Dn9rpsgl.js +0 -169
  265. package/dist/assets/main-qSeeJRW1.css +0 -1
  266. package/dist/assets/markdown-worker-CptoICmq.js +0 -2
  267. package/dist/assets/markdownTheme-DOfGg-A7.js +0 -1
  268. package/dist/assets/miniChat-BpjxVqgg.js +0 -2
  269. package/dist/assets/mobile-nMJCaNq1.js +0 -2
  270. package/dist/assets/multirun-BvSG94mW.js +0 -4
  271. package/dist/assets/persistence-Ce3AnpEr.js +0 -3
  272. package/dist/assets/pl-CnaKWN5F.js +0 -15
  273. package/dist/assets/platform-CWTIp02q.js +0 -4
  274. package/dist/assets/projectMeta-DOe49dM_.js +0 -3
  275. package/dist/assets/pt-BR-s8OpfRI6.js +0 -15
  276. package/dist/assets/renderElectronMiniChatApp-R6dKh79f.js +0 -2
  277. package/dist/assets/renderMobileApp-y-S8y8v_.js +0 -28
  278. package/dist/assets/runtime-fetch-zIalUnbb.js +0 -1
  279. package/dist/assets/runtimeEndpointReset-B6BxhvR0.js +0 -147
  280. package/dist/assets/sessionChangeSummary-VVBlEXuh.js +0 -2
  281. package/dist/assets/src-BzX5UI-c.css +0 -2
  282. package/dist/assets/store-DJczmczv.js +0 -15
  283. package/dist/assets/textarea-3XR8sUfy.js +0 -1
  284. package/dist/assets/uk-CaX0iqkB.js +0 -15
  285. package/dist/assets/useDesktopWindowControlsLayout-B1GX59-L.js +0 -1
  286. package/dist/assets/useEffectiveDirectory-CN6JoCFw.js +0 -1
  287. package/dist/assets/useMobileNavigationStore-BVjFtXSy.js +0 -414
  288. package/dist/assets/useSessionAutoCleanup-BLjymaFt.js +0 -2
  289. package/dist/assets/useUIStore-Bxv3f23A.js +0 -1
  290. package/dist/assets/vendor-opencode-client-BOe2qpQb.js +0 -12
  291. package/dist/assets/zh-CN-20MYEjha.js +0 -15
  292. package/dist/assets/zh-TW-DiN04qTw.js +0 -15
  293. package/server/lib/small-model/call.js +0 -427
  294. package/server/lib/small-model/call.test.js +0 -382
  295. package/server/lib/small-model/opencode-session.js +0 -183
  296. package/server/lib/small-model/opencode-session.test.js +0 -147
@@ -1,22 +1,30 @@
1
1
  /**
2
- * OpenChamber-owned session metadata.
2
+ * OpenChamber session metadata cache and migration source.
3
3
  *
4
- * OpenCode 2.x accepts `metadata` only when a session is created; the v1
5
- * `PATCH /session/{id}` route is gone and nothing replaces it. Session goal
6
- * progress (and later assist / pinned notes) live here: one JSON file per data
7
- * dir, `{ [sessionID]: metadata }`, folded back onto the sessions the proxy
8
- * serves so clients keep reading `session.metadata` where they always did.
4
+ * The OpenCode session record is the durable authority for title, archive, and
5
+ * other OpenChamber metadata (`opencode-session-record.js`). This JSON file
6
+ * remains the cache and the pre-migration source. A failed or corrupt read is
7
+ * never empty success: the store stays unwritable and does not overwrite
8
+ * unknown disk state or the OpenCode record.
9
9
  *
10
10
  * Writes are a JSON Merge Patch (RFC 7386): nested objects merge key by key and
11
- * a `null` deletes. That is what the old PATCH did, and it is what keeps two
12
- * features writing into the same `openchamber` namespace from erasing each
13
- * other — goal mode saving progress must not drop an assist recap.
11
+ * a `null` deletes. That keeps two features writing into the same `openchamber`
12
+ * namespace from erasing each other — goal mode saving progress must not drop
13
+ * an assist recap. When a record reader and writer are wired, the merged object
14
+ * is written onto the OpenCode session before the local cache commits. A failed
15
+ * record read is not treated as empty metadata.
16
+ *
17
+ * Mutations are fully serialized. Committed `entries` update only after a
18
+ * successful persist so concurrent readers never observe uncommitted drafts.
14
19
  */
15
20
 
16
21
  import fsDefault from 'node:fs';
17
22
  import pathDefault from 'node:path';
18
23
 
24
+ import { mergeOwnedMetadata } from './opencode-session-record.js';
25
+
19
26
  const METADATA_FILE_NAME = 'sessions-metadata.json';
27
+ const HOST_ARCHIVE_KEY = 'archive';
20
28
 
21
29
  const asNonEmptyString = (value) => {
22
30
  if (typeof value !== 'string') return null;
@@ -43,34 +51,98 @@ export const mergeMetadataPatch = (current, patch) => {
43
51
  return base;
44
52
  };
45
53
 
54
+ const readPriorArchivedAt = (metadata) => {
55
+ if (!isPlainObject(metadata?.openchamber)) return null;
56
+ if (!Object.prototype.hasOwnProperty.call(metadata.openchamber, HOST_ARCHIVE_KEY)) return null;
57
+ const archive = metadata.openchamber[HOST_ARCHIVE_KEY];
58
+ if (!isPlainObject(archive) || !Object.prototype.hasOwnProperty.call(archive, 'archivedAt')) return null;
59
+ const value = archive.archivedAt;
60
+ return typeof value === 'number' && Number.isFinite(value) ? value : null;
61
+ };
62
+
63
+ /** Strip Host archive writes from generic metadata patches. */
64
+ const stripArchiveFromMetadataPatch = (patch) => {
65
+ if (!isPlainObject(patch) || !isPlainObject(patch.openchamber)) return patch;
66
+ if (!Object.prototype.hasOwnProperty.call(patch.openchamber, HOST_ARCHIVE_KEY)) return patch;
67
+ const openchamber = { ...patch.openchamber };
68
+ delete openchamber[HOST_ARCHIVE_KEY];
69
+ return { ...patch, openchamber };
70
+ };
71
+
72
+ /**
73
+ * Restore reserved archive after a generic merge so null ancestor deletes
74
+ * (`openchamber: null` / `archive: null`) cannot wipe Host archive state.
75
+ */
76
+ const applyReservedArchiveProtection = (previous, patch, merged, { allowArchive = false } = {}) => {
77
+ if (allowArchive) return merged;
78
+ const priorArchive = readPriorArchivedAt(previous);
79
+ const result = isPlainObject(merged) ? { ...merged } : {};
80
+ const openchamber = isPlainObject(result.openchamber) ? { ...result.openchamber } : {};
81
+
82
+ if (isPlainObject(patch?.openchamber) && Object.prototype.hasOwnProperty.call(patch.openchamber, HOST_ARCHIVE_KEY)) {
83
+ delete openchamber[HOST_ARCHIVE_KEY];
84
+ }
85
+
86
+ if (priorArchive !== null) {
87
+ openchamber[HOST_ARCHIVE_KEY] = {
88
+ ...(isPlainObject(previous?.openchamber?.[HOST_ARCHIVE_KEY])
89
+ ? previous.openchamber[HOST_ARCHIVE_KEY]
90
+ : {}),
91
+ archivedAt: priorArchive,
92
+ };
93
+ } else {
94
+ delete openchamber[HOST_ARCHIVE_KEY];
95
+ }
96
+
97
+ if (Object.keys(openchamber).length > 0) result.openchamber = openchamber;
98
+ else delete result.openchamber;
99
+ return result;
100
+ };
101
+
46
102
  /**
47
103
  * @param {object} options
48
104
  * @param {string} options.dataDir OpenChamber data directory for this instance.
49
105
  * @param {typeof fsDefault.promises} [options.fsPromises]
50
106
  * @param {typeof pathDefault} [options.path]
51
107
  * @param {() => number} [options.now]
108
+ * @param {(sessionID: string, directory?: string | null) => Promise<object | null>} [options.recordReader]
109
+ * OpenCode session read. Null means the session is gone. Any other failure throws.
110
+ * @param {(sessionID: string, metadata: object, directory?: string | null) => Promise<void>} [options.recordWriter]
111
+ * Writes the full merged metadata onto the OpenCode session record. Must not send title.
52
112
  */
53
113
  export const createSessionMetadataStore = ({
54
114
  dataDir,
55
115
  fsPromises = fsDefault.promises,
56
116
  path = pathDefault,
57
117
  now = Date.now,
118
+ recordReader = null,
119
+ recordWriter = null,
58
120
  }) => {
121
+ if (typeof recordWriter === 'function' && typeof recordReader !== 'function') {
122
+ throw new Error('session record writer requires a record reader');
123
+ }
59
124
  const filePath = path.join(dataDir, METADATA_FILE_NAME);
60
125
 
61
- /** sessionID → metadata object. Authoritative once `loaded` is true. */
126
+ /** sessionID → metadata. Committed only — never holds an in-flight draft. */
62
127
  const entries = new Map();
63
128
  let loaded = false;
64
129
  let loadPromise = null;
65
130
  /**
66
131
  * Writes stay disabled until one load has told us what is already on disk.
67
- * A failed read is not evidence that nothing is stored, and writing over a
68
- * file we could not read would drop exactly the state we are protecting.
132
+ * A failed/corrupt read is not evidence that nothing is stored.
69
133
  */
70
134
  let writable = false;
71
- let writeChain = Promise.resolve();
135
+ let loadFailureReason = null;
136
+ /** Full RMW + persist chain — concurrent writers cannot interleave merges. */
137
+ let mutationChain = Promise.resolve();
138
+
139
+ const snapshotFrom = (map) => Object.fromEntries(map);
140
+ const snapshot = () => snapshotFrom(entries);
72
141
 
73
- const snapshot = () => Object.fromEntries(entries);
142
+ const unavailableError = (detail = loadFailureReason) => {
143
+ const suffix = detail ? `: ${detail}` : ': its file could not be read';
144
+ return new Error(`session metadata is unavailable${suffix.startsWith(':') ? suffix : `: ${suffix}`}`);
145
+ };
74
146
 
75
147
  const parseStored = (raw) => {
76
148
  const parsed = JSON.parse(raw);
@@ -80,8 +152,6 @@ export const createSessionMetadataStore = ({
80
152
  const result = new Map();
81
153
  for (const [sessionID, value] of Object.entries(parsed)) {
82
154
  const id = asNonEmptyString(sessionID);
83
- // A non-object entry is not metadata; dropping it is better than handing
84
- // a consumer something it will read fields off.
85
155
  if (id && isPlainObject(value)) result.set(id, value);
86
156
  }
87
157
  return result;
@@ -92,25 +162,20 @@ export const createSessionMetadataStore = ({
92
162
  try {
93
163
  raw = await fsPromises.readFile(filePath, 'utf8');
94
164
  } catch (error) {
95
- // No file yet is the normal first run: nothing is stored, and writing is
96
- // safe because there is no state to lose.
97
165
  if (error?.code === 'ENOENT') return { ok: true, stored: new Map() };
98
166
  console.warn('[session-metadata] could not read the session metadata file:', error?.message ?? error);
99
- return { ok: false, stored: null };
167
+ return { ok: false, stored: null, reason: error?.message || 'read failed' };
100
168
  }
101
169
 
102
170
  try {
103
171
  return { ok: true, stored: parseStored(raw) };
104
172
  } catch (error) {
105
- // Malformed bytes are kept for the user instead of being overwritten on
106
- // the next write, and whatever this process already knows stays in memory
107
- // rather than collapsing to "no metadata".
108
- const backup = `${filePath}.corrupt-${now()}`;
109
- await fsPromises.rename(filePath, backup).catch(() => undefined);
173
+ // Corrupt bytes must not become empty success. Keep the file, stay
174
+ // unwritable, and surface the error so callers can report/retry.
110
175
  console.warn(
111
- `[session-metadata] session metadata file was unreadable and was moved to ${backup}: ${error?.message ?? error}`,
176
+ `[session-metadata] session metadata file is corrupt and will not be overwritten: ${error?.message ?? error}`,
112
177
  );
113
- return { ok: true, stored: new Map() };
178
+ return { ok: false, stored: null, reason: error?.message || 'corrupt metadata file' };
114
179
  }
115
180
  };
116
181
 
@@ -118,117 +183,239 @@ export const createSessionMetadataStore = ({
118
183
  if (!loadPromise) {
119
184
  loadPromise = readFile().then((result) => {
120
185
  if (result.ok) {
121
- // Merge rather than replace: a write that happened while the first
122
- // load was still running must survive it.
123
186
  for (const [id, metadata] of result.stored) {
124
187
  if (!entries.has(id)) entries.set(id, metadata);
125
188
  }
126
189
  loaded = true;
127
190
  writable = true;
191
+ loadFailureReason = null;
128
192
  } else {
129
- // Let a later call retry; until one succeeds the store answers reads
130
- // from memory and refuses writes.
131
193
  loadPromise = null;
194
+ loaded = false;
195
+ writable = false;
196
+ loadFailureReason = result.reason || 'its file could not be read';
132
197
  }
133
- return { ok: result.ok, entries: snapshot() };
198
+ return { ok: result.ok, entries: snapshot(), reason: result.reason || null };
134
199
  });
135
200
  }
136
201
  return loadPromise;
137
202
  };
138
203
 
139
- const persist = () => {
140
- const payload = JSON.stringify(snapshot());
141
- const write = async () => {
142
- await fsPromises.mkdir(dataDir, { recursive: true });
143
- // Temp file in the same directory so the rename is atomic on one device:
144
- // a reader sees either the previous file or the complete new one.
145
- const tmpPath = `${filePath}.${process.pid}.tmp`;
146
- await fsPromises.writeFile(tmpPath, payload, 'utf8');
147
- await fsPromises.rename(tmpPath, filePath);
148
- };
149
- // Serialized so two overlapping writes cannot interleave their renames.
150
- const next = writeChain.then(write, write);
151
- writeChain = next.catch(() => undefined);
204
+ const ensureReadable = async () => {
205
+ const result = await load();
206
+ if (!result.ok || !writable) throw unavailableError(result.reason);
207
+ return result;
208
+ };
209
+
210
+ const persistMap = async (map) => {
211
+ const payload = JSON.stringify(snapshotFrom(map));
212
+ await fsPromises.mkdir(dataDir, { recursive: true });
213
+ const tmpPath = `${filePath}.${process.pid}.tmp`;
214
+ await fsPromises.writeFile(tmpPath, payload, 'utf8');
215
+ await fsPromises.rename(tmpPath, filePath);
216
+ };
217
+
218
+ /**
219
+ * @template T
220
+ * @param {() => Promise<T>} work
221
+ * @returns {Promise<T>}
222
+ */
223
+ const runExclusive = (work) => {
224
+ const next = mutationChain.then(work, work);
225
+ mutationChain = next.then(
226
+ () => undefined,
227
+ () => undefined,
228
+ );
152
229
  return next;
153
230
  };
154
231
 
155
- const get = async (sessionID) => {
232
+ const readRecordMetadata = async (id, directory) => {
233
+ if (typeof recordReader !== 'function') return undefined;
234
+ try {
235
+ const record = await recordReader(id, directory);
236
+ if (record == null) return null;
237
+ return isPlainObject(record.metadata) ? record.metadata : {};
238
+ } catch (error) {
239
+ if (entries.has(id)) return undefined;
240
+ throw error;
241
+ }
242
+ };
243
+
244
+ const get = async (sessionID, options = {}) => {
156
245
  const id = asNonEmptyString(sessionID);
157
246
  if (!id) return {};
158
- await load();
159
- return entries.get(id) ?? {};
247
+ await ensureReadable();
248
+ const side = entries.get(id);
249
+ const fromRecord = await readRecordMetadata(id, options.directory);
250
+ if (fromRecord === undefined) return side ?? {};
251
+ if (fromRecord === null) return side ?? {};
252
+ return mergeOwnedMetadata(fromRecord, side);
160
253
  };
161
254
 
162
- /** Whether the store holds anything for the session (an empty object counts as nothing). */
163
255
  const has = async (sessionID) => {
164
256
  const id = asNonEmptyString(sessionID);
165
257
  if (!id) return false;
166
- await load();
258
+ await ensureReadable();
167
259
  return entries.has(id);
168
260
  };
169
261
 
262
+ /**
263
+ * Shared exclusive commit path: merge → persist draft → publish committed.
264
+ * Readers never observe the draft; persist failure leaves prior committed state.
265
+ *
266
+ * @param {string} id
267
+ * @param {object | undefined} previous
268
+ * @param {object} patch
269
+ * @param {{ allowArchive?: boolean }} options
270
+ */
271
+ const commitMergedPatch = async (id, previous, patch, options = {}) => {
272
+ const allowArchive = options.allowArchive === true;
273
+ let base = previous;
274
+ if (typeof recordReader === 'function') {
275
+ let record;
276
+ try {
277
+ record = await recordReader(id, options.directory);
278
+ } catch (error) {
279
+ // A failed read is not an empty record. Do not replace metadata we could not see.
280
+ throw error;
281
+ }
282
+ if (record == null) {
283
+ const error = new Error(`session ${id} was not found`);
284
+ error.status = 404;
285
+ throw error;
286
+ }
287
+ base = mergeOwnedMetadata(record.metadata, previous);
288
+ }
289
+ const effectivePatch = allowArchive ? patch : stripArchiveFromMetadataPatch(patch);
290
+
291
+ let merged = mergeMetadataPatch(base, effectivePatch);
292
+ merged = applyReservedArchiveProtection(base, patch, merged, { allowArchive });
293
+
294
+ if (typeof recordWriter === 'function') {
295
+ await recordWriter(id, merged, options.directory);
296
+ }
297
+
298
+ const nextMap = new Map(entries);
299
+ if (Object.keys(merged).length === 0) nextMap.delete(id);
300
+ else nextMap.set(id, merged);
301
+
302
+ // OpenCode write already landed. Publish the local cache only after its persist succeeds.
303
+ await persistMap(nextMap);
304
+
305
+ if (Object.keys(merged).length === 0) entries.delete(id);
306
+ else entries.set(id, merged);
307
+
308
+ return merged;
309
+ };
310
+
170
311
  /**
171
312
  * Applies a merge patch and returns the session's full metadata afterwards.
172
- * A failed write rolls the memory back and throws, so a caller never believes
173
- * it saved something it did not.
313
+ * Committed map updates only after persist succeeds — readers never see drafts.
314
+ *
315
+ * @param {string} sessionID
316
+ * @param {object} patch
317
+ * @param {{ allowArchive?: boolean }} [options]
174
318
  */
175
- const setSessionMetadata = async (sessionID, patch) => {
319
+ const setSessionMetadata = async (sessionID, patch, options = {}) => {
176
320
  const id = asNonEmptyString(sessionID);
177
321
  if (!id) throw new Error('a session id is required to store session metadata');
178
322
  if (!isPlainObject(patch)) throw new Error('a session metadata patch must be an object');
179
323
 
180
- const loadResult = await load();
181
- if (!loadResult.ok || !writable) {
182
- throw new Error('session metadata is unavailable: its file could not be read');
183
- }
324
+ return runExclusive(async () => {
325
+ await ensureReadable();
326
+ return commitMergedPatch(id, entries.get(id), patch, options);
327
+ });
328
+ };
184
329
 
185
- const had = entries.has(id);
186
- const previous = entries.get(id);
187
- const merged = mergeMetadataPatch(previous, patch);
188
- if (Object.keys(merged).length === 0) entries.delete(id);
189
- else entries.set(id, merged);
330
+ /**
331
+ * Conditional metadata mutation on the same exclusive serial boundary as
332
+ * setSessionMetadata. `decide(committedMetadata)` runs while the lock is held
333
+ * and must return synchronously:
334
+ * - `{ ok: false, reason? }` — no persist; readers keep prior committed state
335
+ * - `{ ok: true, patch }` — merge-patch, persist, then publish
336
+ *
337
+ * Use this for goal generation / status preconditions so a stale auditor
338
+ * cannot commit after a user pause that already landed.
339
+ *
340
+ * @param {string} sessionID
341
+ * @param {(current: object) => { ok: false, reason?: string } | { ok: true, patch: object }} decide
342
+ * @param {{ allowArchive?: boolean }} [options]
343
+ * @returns {Promise<{ committed: boolean, reason?: string, metadata: object }>}
344
+ */
345
+ const mutateSessionMetadata = async (sessionID, decide, options = {}) => {
346
+ const id = asNonEmptyString(sessionID);
347
+ if (!id) throw new Error('a session id is required to store session metadata');
348
+ if (typeof decide !== 'function') throw new Error('a decide function is required to mutate session metadata');
190
349
 
191
- try {
192
- await persist();
193
- } catch (error) {
194
- if (had) entries.set(id, previous);
195
- else entries.delete(id);
196
- throw error;
197
- }
350
+ return runExclusive(async () => {
351
+ await ensureReadable();
198
352
 
199
- return merged;
353
+ const previous = entries.get(id);
354
+ const current = previous ?? {};
355
+ const decision = decide(current);
356
+ if (!decision || decision.ok !== true) {
357
+ return {
358
+ committed: false,
359
+ reason: typeof decision?.reason === 'string' && decision.reason
360
+ ? decision.reason
361
+ : 'rejected',
362
+ metadata: current,
363
+ };
364
+ }
365
+ if (!isPlainObject(decision.patch)) {
366
+ throw new Error('a successful mutate decision must include an object patch');
367
+ }
368
+
369
+ const merged = await commitMergedPatch(id, previous, decision.patch, options);
370
+ return { committed: true, metadata: merged };
371
+ });
200
372
  };
201
373
 
374
+ /**
375
+ * Drop a session's metadata.
376
+ * @returns {Promise<boolean>} true if a row was removed; false if already absent
377
+ * (idempotent). Disk/write failures **throw** so callers can retry — never
378
+ * conflate not-found with write failure.
379
+ */
202
380
  const removeSession = async (sessionID) => {
203
381
  const id = asNonEmptyString(sessionID);
204
382
  if (!id) return false;
205
- await load();
206
- if (!writable || !entries.has(id)) return false;
207
- const previous = entries.get(id);
208
- entries.delete(id);
209
- try {
210
- await persist();
211
- } catch (error) {
212
- entries.set(id, previous);
213
- console.warn('[session-metadata] failed to drop session metadata:', error?.message ?? error);
214
- return false;
215
- }
216
- return true;
383
+
384
+ return runExclusive(async () => {
385
+ await ensureReadable();
386
+ if (!entries.has(id)) return false;
387
+
388
+ const nextMap = new Map(entries);
389
+ nextMap.delete(id);
390
+ // Write failures propagate — do not report as "already gone".
391
+ await persistMap(nextMap);
392
+ entries.delete(id);
393
+ return true;
394
+ });
217
395
  };
218
396
 
219
397
  const getAll = async () => {
220
- await load();
398
+ await ensureReadable();
221
399
  return snapshot();
222
400
  };
223
401
 
402
+ /**
403
+ * Synchronous committed snapshot for event hot paths.
404
+ * Returns `null` when not successfully loaded (unknown — never empty).
405
+ */
406
+ const getSnapshotSync = () => (loaded && writable ? snapshot() : null);
407
+
224
408
  return {
225
409
  load,
226
410
  get,
227
411
  getAll,
228
412
  list: getAll,
229
- isLoaded: () => loaded,
413
+ getSnapshotSync,
414
+ isLoaded: () => loaded && writable,
415
+ getLoadFailureReason: () => loadFailureReason,
230
416
  has,
231
417
  setSessionMetadata,
418
+ mutateSessionMetadata,
232
419
  removeSession,
233
420
  filePath,
234
421
  };