@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
@@ -0,0 +1,57 @@
1
+ export function mergeOwnedMetadata(
2
+ openCodeMetadata: unknown,
3
+ sideMetadata: unknown,
4
+ ): Record<string, unknown>;
5
+
6
+ export function writeSessionRecordViaClient(
7
+ client: { session?: { update?: (input: unknown, requestOptions?: unknown) => Promise<unknown> } } | null | undefined,
8
+ input: {
9
+ sessionID: string;
10
+ metadata?: Record<string, unknown>;
11
+ title?: string;
12
+ requestOptions?: object;
13
+ },
14
+ ): Promise<void>;
15
+
16
+ export function readSessionRecordViaClient(
17
+ client: { session?: { get?: (input: unknown, requestOptions?: unknown) => Promise<unknown> } } | null | undefined,
18
+ input: { sessionID: string; requestOptions?: object },
19
+ ): Promise<Record<string, unknown> | null>;
20
+
21
+ export function createHttpSessionRecordClient(options: {
22
+ buildSessionUrl: (sessionID: string, directory?: string | null) => string;
23
+ getHeaders?: () => Record<string, string>;
24
+ fetchFn?: typeof fetch;
25
+ }): {
26
+ read: (sessionID: string, directory?: string | null) => Promise<Record<string, unknown> | null>;
27
+ write: (input: {
28
+ sessionID: string;
29
+ metadata?: Record<string, unknown>;
30
+ title?: string;
31
+ directory?: string | null;
32
+ }) => Promise<void>;
33
+ };
34
+
35
+ export function migrateLoadedSideStore(input: {
36
+ loadResult: { ok?: boolean; entries?: Record<string, object> | null } | null;
37
+ readSession: (sessionID: string) => Promise<object | null>;
38
+ writeMetadata: (sessionID: string, metadata: object, record: object) => Promise<void>;
39
+ }): Promise<{
40
+ status: 'unavailable' | 'partial' | 'complete';
41
+ migrated: string[];
42
+ failed: Array<{ id: string; error: string }>;
43
+ absent: string[];
44
+ }>;
45
+
46
+ export function retainOpenCodeSessions(
47
+ openCodeSessions: unknown,
48
+ sideById: Record<string, object> | null | undefined,
49
+ ): unknown[];
50
+
51
+ export function startSideStoreMigration(options: {
52
+ load: () => Promise<{ ok?: boolean; entries?: Record<string, object> }>;
53
+ readSession: (sessionID: string) => Promise<object | null>;
54
+ writeMetadata: (sessionID: string, metadata: object, record: object) => Promise<void>;
55
+ setTimer?: typeof setTimeout;
56
+ clearTimer?: typeof clearTimeout;
57
+ }): () => void;
@@ -0,0 +1,316 @@
1
+ /**
2
+ * OpenCode session-record ownership for OpenChamber metadata.
3
+ *
4
+ * OpenCode 2.0.15 `PATCH /api/session/:id` (`session.update`) replaces
5
+ * `metadata` wholesale and can set `title`. Archive stays in
6
+ * `metadata.openchamber.archive` on that same record, then projects onto
7
+ * `time.archived`. This module is the only place that knows that call.
8
+ *
9
+ * A client whose types do not yet expose `session.update` metadata still goes
10
+ * through `writeSessionRecordViaClient` / `createHttpSessionRecordClient`.
11
+ * Missing the API throws — it is never an empty success.
12
+ *
13
+ * The side store is a migration source. A failed load is not an empty catalog.
14
+ * One session's migration failure does not delete other sessions or their rows.
15
+ */
16
+
17
+ import { mergeOwnedMetadata, projectSessionWithStoredMap } from './session-projection.js';
18
+
19
+ export { mergeOwnedMetadata };
20
+
21
+ const isPlainObject = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
22
+
23
+ const asNonEmptyString = (value) => {
24
+ if (typeof value !== 'string') return null;
25
+ const trimmed = value.trim();
26
+ return trimmed.length > 0 ? trimmed : null;
27
+ };
28
+
29
+ const isSessionNotFoundError = (error) => {
30
+ const status = Number(error?.status ?? error?.statusCode);
31
+ if (status === 404) return true;
32
+ const tag = error?._tag || error?.name;
33
+ return tag === 'SessionNotFoundError' || tag === 'Session.NotFoundError';
34
+ };
35
+
36
+ const deepEqual = (left, right) => {
37
+ if (left === right) return true;
38
+ if (Array.isArray(left) || Array.isArray(right)) {
39
+ if (!Array.isArray(left) || !Array.isArray(right) || left.length !== right.length) return false;
40
+ return left.every((item, index) => deepEqual(item, right[index]));
41
+ }
42
+ if (!isPlainObject(left) || !isPlainObject(right)) return false;
43
+ const leftKeys = Object.keys(left);
44
+ const rightKeys = Object.keys(right);
45
+ if (leftKeys.length !== rightKeys.length) return false;
46
+ return leftKeys.every((key) => deepEqual(left[key], right[key]));
47
+ };
48
+
49
+ const unwrapSession = (payload) => {
50
+ if (!isPlainObject(payload)) return null;
51
+ if (isPlainObject(payload.data) && typeof payload.data.id === 'string') return payload.data;
52
+ if (typeof payload.id === 'string') return payload;
53
+ return null;
54
+ };
55
+
56
+ /**
57
+ * Structural adapter for `@opencode/client` session.update.
58
+ * Throws when the method is absent — never pretends the write landed.
59
+ *
60
+ * @param {{ session?: { update?: Function } } | null | undefined} client
61
+ * @param {{ sessionID: string, metadata?: object, title?: string, requestOptions?: object }} input
62
+ */
63
+ export const writeSessionRecordViaClient = async (client, input) => {
64
+ const update = client?.session?.update;
65
+ if (typeof update !== 'function') {
66
+ const error = new Error('session.update is unavailable');
67
+ error.code = 'session_update_unavailable';
68
+ throw error;
69
+ }
70
+ const sessionID = asNonEmptyString(input?.sessionID);
71
+ if (!sessionID) throw new Error('a session id is required to update the OpenCode session record');
72
+ const body = { sessionID };
73
+ if (input.metadata !== undefined) body.metadata = input.metadata;
74
+ if (input.title !== undefined) body.title = input.title;
75
+ await update(body, input.requestOptions);
76
+ };
77
+
78
+ /**
79
+ * @param {{ session?: { get?: Function } } | null | undefined} client
80
+ * @param {{ sessionID: string, requestOptions?: object }} input
81
+ * @returns {Promise<object | null>} null only when OpenCode confirms the session is gone
82
+ */
83
+ export const readSessionRecordViaClient = async (client, input) => {
84
+ const get = client?.session?.get;
85
+ if (typeof get !== 'function') {
86
+ const error = new Error('session.get is unavailable');
87
+ error.code = 'session_get_unavailable';
88
+ throw error;
89
+ }
90
+ const sessionID = asNonEmptyString(input?.sessionID);
91
+ if (!sessionID) throw new Error('a session id is required to read the OpenCode session record');
92
+ try {
93
+ const session = await get({ sessionID }, input.requestOptions);
94
+ return unwrapSession(session) || (isPlainObject(session) ? session : null);
95
+ } catch (error) {
96
+ if (isSessionNotFoundError(error)) return null;
97
+ throw error;
98
+ }
99
+ };
100
+
101
+ /**
102
+ * HTTP owner of `GET` / `PATCH /api/session/:id`. Callers supply the URL so
103
+ * web and VS Code keep their own prefix rules.
104
+ *
105
+ * @param {{
106
+ * buildSessionUrl: (sessionID: string, directory?: string | null) => string,
107
+ * getHeaders?: () => Record<string, string>,
108
+ * fetchFn?: typeof fetch,
109
+ * }} options
110
+ */
111
+ export const createHttpSessionRecordClient = ({
112
+ buildSessionUrl,
113
+ getHeaders = () => ({}),
114
+ fetchFn = null,
115
+ }) => {
116
+ if (typeof buildSessionUrl !== 'function') {
117
+ throw new Error('createHttpSessionRecordClient requires buildSessionUrl');
118
+ }
119
+
120
+ const request = async (sessionID, { method, directory, body }) => {
121
+ const fetchImpl = fetchFn || globalThis.fetch;
122
+ const id = asNonEmptyString(sessionID);
123
+ if (!id) throw new Error('a session id is required');
124
+ const url = buildSessionUrl(id, directory);
125
+ const response = await fetchImpl(url, {
126
+ method,
127
+ headers: {
128
+ Accept: 'application/json',
129
+ ...(body !== undefined ? { 'Content-Type': 'application/json' } : {}),
130
+ ...(typeof getHeaders === 'function' ? getHeaders() : {}),
131
+ },
132
+ ...(body !== undefined ? { body: JSON.stringify(body) } : {}),
133
+ });
134
+ return response;
135
+ };
136
+
137
+ const read = async (sessionID, directory) => {
138
+ const response = await request(sessionID, { method: 'GET', directory });
139
+ if (response.status === 404) return null;
140
+ if (!response.ok) {
141
+ const error = new Error(`OpenCode session.get failed (${response.status})`);
142
+ error.status = response.status;
143
+ throw error;
144
+ }
145
+ const payload = await response.json().catch(() => null);
146
+ const session = unwrapSession(payload);
147
+ if (!session) {
148
+ const error = new Error('OpenCode session.get returned an unreadable record');
149
+ error.status = 502;
150
+ throw error;
151
+ }
152
+ return session;
153
+ };
154
+
155
+ const write = async ({ sessionID, directory, metadata, title } = {}) => {
156
+ const body = {};
157
+ if (metadata !== undefined) body.metadata = metadata;
158
+ if (title !== undefined) body.title = title;
159
+ if (Object.keys(body).length === 0) {
160
+ throw new Error('session record write requires metadata or title');
161
+ }
162
+ const response = await request(sessionID, { method: 'PATCH', directory, body });
163
+ if (response.status === 404) {
164
+ const error = new Error(`session ${sessionID} was not found`);
165
+ error.status = 404;
166
+ throw error;
167
+ }
168
+ if (!response.ok) {
169
+ const error = new Error(`OpenCode session.update failed (${response.status})`);
170
+ error.status = response.status;
171
+ throw error;
172
+ }
173
+ };
174
+
175
+ return { read, write };
176
+ };
177
+
178
+ /**
179
+ * Copy side-store rows onto OpenCode records. Refuses a failed load instead of
180
+ * treating it as an empty catalog. A failed session is reported and skipped;
181
+ * other sessions, including ones OpenCode has and the side store does not, are
182
+ * left in place.
183
+ *
184
+ * @param {{
185
+ * loadResult: { ok?: boolean, entries?: Record<string, object> | null } | null,
186
+ * readSession: (sessionID: string) => Promise<object | null>,
187
+ * writeMetadata: (sessionID: string, metadata: object, record: object) => Promise<void>,
188
+ * }} input
189
+ */
190
+ export const migrateLoadedSideStore = async ({ loadResult, readSession, writeMetadata }) => {
191
+ if (!loadResult || loadResult.ok !== true || !isPlainObject(loadResult.entries)) {
192
+ return { status: 'unavailable', migrated: [], failed: [], absent: [] };
193
+ }
194
+ if (typeof readSession !== 'function' || typeof writeMetadata !== 'function') {
195
+ throw new Error('side-store migration requires readSession and writeMetadata');
196
+ }
197
+
198
+ const migrated = [];
199
+ const failed = [];
200
+ const absent = [];
201
+
202
+ for (const [sessionID, sideMetadata] of Object.entries(loadResult.entries)) {
203
+ if (!isPlainObject(sideMetadata)) continue;
204
+ try {
205
+ const record = await readSession(sessionID);
206
+ if (record == null) {
207
+ absent.push(sessionID);
208
+ continue;
209
+ }
210
+ const merged = mergeOwnedMetadata(record.metadata, sideMetadata);
211
+ if (!deepEqual(merged, isPlainObject(record.metadata) ? record.metadata : {})) {
212
+ await writeMetadata(sessionID, merged, record);
213
+ }
214
+ migrated.push(sessionID);
215
+ } catch (error) {
216
+ failed.push({
217
+ id: sessionID,
218
+ error: error instanceof Error ? error.message : String(error),
219
+ });
220
+ }
221
+ }
222
+
223
+ return {
224
+ status: failed.length > 0 ? 'partial' : 'complete',
225
+ migrated,
226
+ failed,
227
+ absent,
228
+ };
229
+ };
230
+
231
+ /**
232
+ * Membership comes from OpenCode. A missing side-store row does not drop the
233
+ * session. A null side map is an unread store, not an empty catalog.
234
+ *
235
+ * @param {unknown} openCodeSessions
236
+ * @param {Record<string, object> | null | undefined} sideById
237
+ */
238
+ export const retainOpenCodeSessions = (openCodeSessions, sideById) => {
239
+ if (!Array.isArray(openCodeSessions)) {
240
+ throw new Error('OpenCode session list is unavailable');
241
+ }
242
+ if (sideById == null || typeof sideById !== 'object' || Array.isArray(sideById)) {
243
+ throw new Error('session metadata is unavailable');
244
+ }
245
+ return openCodeSessions.map((session) => projectSessionWithStoredMap(session, sideById));
246
+ };
247
+
248
+ const MIGRATION_RETRY_MS = 2_000;
249
+
250
+ /**
251
+ * Retry until every loaded side-store row is copied or confirmed absent.
252
+ * A failed load schedules another attempt and does not write empty metadata.
253
+ *
254
+ * @param {{
255
+ * load: () => Promise<{ ok?: boolean, entries?: Record<string, object> }>,
256
+ * readSession: (sessionID: string) => Promise<object | null>,
257
+ * writeMetadata: (sessionID: string, metadata: object, record: object) => Promise<void>,
258
+ * setTimer?: typeof setTimeout,
259
+ * clearTimer?: typeof clearTimeout,
260
+ * }} options
261
+ * @returns {() => void} stop
262
+ */
263
+ export const startSideStoreMigration = ({
264
+ load,
265
+ readSession,
266
+ writeMetadata,
267
+ setTimer = setTimeout,
268
+ clearTimer = clearTimeout,
269
+ }) => {
270
+ const finished = new Set();
271
+ let stopped = false;
272
+ let timer = null;
273
+
274
+ const schedule = () => {
275
+ if (stopped || timer) return;
276
+ timer = setTimer(() => {
277
+ timer = null;
278
+ void attempt();
279
+ }, MIGRATION_RETRY_MS);
280
+ };
281
+
282
+ const attempt = async () => {
283
+ if (stopped) return;
284
+ let loadResult;
285
+ try {
286
+ loadResult = await load();
287
+ } catch {
288
+ schedule();
289
+ return;
290
+ }
291
+ if (!loadResult || loadResult.ok !== true || !isPlainObject(loadResult.entries)) {
292
+ schedule();
293
+ return;
294
+ }
295
+ const pending = {};
296
+ for (const [id, metadata] of Object.entries(loadResult.entries)) {
297
+ if (!finished.has(id)) pending[id] = metadata;
298
+ }
299
+ if (Object.keys(pending).length === 0) return;
300
+ const result = await migrateLoadedSideStore({
301
+ loadResult: { ok: true, entries: pending },
302
+ readSession,
303
+ writeMetadata,
304
+ });
305
+ for (const id of result.migrated) finished.add(id);
306
+ for (const id of result.absent) finished.add(id);
307
+ if (result.failed.length > 0) schedule();
308
+ };
309
+
310
+ void attempt();
311
+ return () => {
312
+ stopped = true;
313
+ if (timer) clearTimer(timer);
314
+ timer = null;
315
+ };
316
+ };
@@ -0,0 +1,242 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+
3
+ import { createSessionArchiveService } from './session-archive.js';
4
+ import { createSessionMetadataStore } from './session-metadata-store.js';
5
+ import {
6
+ createHttpSessionRecordClient,
7
+ mergeOwnedMetadata,
8
+ migrateLoadedSideStore,
9
+ readSessionRecordViaClient,
10
+ retainOpenCodeSessions,
11
+ writeSessionRecordViaClient,
12
+ } from './opencode-session-record.js';
13
+ import fs from 'node:fs';
14
+ import os from 'node:os';
15
+ import path from 'node:path';
16
+
17
+ const makeDataDir = () => fs.mkdtempSync(path.join(os.tmpdir(), 'openchamber-record-'));
18
+
19
+ describe('OpenCode session record ownership', () => {
20
+ it('keeps title and archive on the OpenCode record across archive and restore', async () => {
21
+ const dataDir = makeDataDir();
22
+ const records = new Map([
23
+ ['ses_1', {
24
+ id: 'ses_1',
25
+ title: 'Keep me',
26
+ metadata: { openchamber: { goal: { status: 'active' }, assistant: { assistantID: 'ast_1' } } },
27
+ time: { created: 1, updated: 2 },
28
+ }],
29
+ ]);
30
+ const writes = [];
31
+ const store = createSessionMetadataStore({
32
+ dataDir,
33
+ recordReader: async (sessionID) => records.get(sessionID) ?? null,
34
+ recordWriter: async (sessionID, metadata) => {
35
+ const current = records.get(sessionID);
36
+ if (!current) {
37
+ const error = new Error('missing');
38
+ error.status = 404;
39
+ throw error;
40
+ }
41
+ writes.push({ sessionID, metadata, title: current.title });
42
+ records.set(sessionID, { ...current, metadata });
43
+ },
44
+ });
45
+ const archive = createSessionArchiveService({
46
+ sessionMetadataStore: store,
47
+ fetchUpstreamSession: async ({ sessionID }) => records.get(sessionID) ?? null,
48
+ });
49
+
50
+ const archived = await archive.setArchive({ sessionID: 'ses_1', archivedAt: 50, directory: '/repo' });
51
+ expect(archived.session.title).toBe('Keep me');
52
+ expect(archived.session.time.archived).toBe(50);
53
+ expect(records.get('ses_1').title).toBe('Keep me');
54
+ expect(records.get('ses_1').metadata.openchamber.archive.archivedAt).toBe(50);
55
+ expect(records.get('ses_1').metadata.openchamber.goal.status).toBe('active');
56
+ expect(records.get('ses_1').metadata.openchamber.assistant.assistantID).toBe('ast_1');
57
+ expect(writes.at(-1).metadata.openchamber.assistant.assistantID).toBe('ast_1');
58
+
59
+ const restored = await archive.setArchive({ sessionID: 'ses_1', archivedAt: 0 });
60
+ expect(restored.session.title).toBe('Keep me');
61
+ expect(restored.session.time.archived).toBeUndefined();
62
+ expect(records.get('ses_1').title).toBe('Keep me');
63
+ expect(records.get('ses_1').metadata.openchamber.archive.archivedAt).toBe(0);
64
+ expect(records.get('ses_1').metadata.openchamber.goal.status).toBe('active');
65
+
66
+ const restarted = createSessionMetadataStore({
67
+ dataDir: makeDataDir(),
68
+ recordReader: async (sessionID) => records.get(sessionID) ?? null,
69
+ recordWriter: async () => undefined,
70
+ });
71
+ await expect(restarted.get('ses_1')).resolves.toMatchObject({
72
+ openchamber: {
73
+ archive: { archivedAt: 0 },
74
+ goal: { status: 'active' },
75
+ assistant: { assistantID: 'ast_1' },
76
+ },
77
+ });
78
+ const visible = retainOpenCodeSessions([records.get('ses_1')], {});
79
+ expect(visible).toHaveLength(1);
80
+ expect(visible[0].title).toBe('Keep me');
81
+ expect(visible[0].time.archived).toBeUndefined();
82
+ });
83
+
84
+ it('does not drop an OpenCode session when the side store has no row', () => {
85
+ const visible = retainOpenCodeSessions([
86
+ {
87
+ id: 'ses_1',
88
+ title: 'Still here',
89
+ metadata: { openchamber: { archive: { archivedAt: 9 } } },
90
+ time: { created: 1, updated: 2 },
91
+ },
92
+ { id: 'ses_2', title: 'Also here', time: { created: 1, updated: 2 } },
93
+ ], {});
94
+
95
+ expect(visible.map((session) => session.id)).toEqual(['ses_1', 'ses_2']);
96
+ expect(visible[0].title).toBe('Still here');
97
+ expect(visible[0].time.archived).toBe(9);
98
+ expect(visible[1].title).toBe('Also here');
99
+ });
100
+
101
+ it('does not treat an unread side store as an empty catalog', () => {
102
+ expect(() => retainOpenCodeSessions([{ id: 'ses_1', title: 'Stay' }], null)).toThrow(/unavailable/);
103
+ expect(() => retainOpenCodeSessions(null, {})).toThrow(/unavailable/);
104
+ });
105
+
106
+ it('does not treat a failed side-store load as an authoritative empty migration', async () => {
107
+ const writes = [];
108
+ const records = {
109
+ ses_1: { id: 'ses_1', title: 'Stay', metadata: { openchamber: { goal: { id: 'g1' } } } },
110
+ };
111
+ const result = await migrateLoadedSideStore({
112
+ loadResult: { ok: false, entries: {} },
113
+ readSession: async (id) => records[id] ?? null,
114
+ writeMetadata: async (id, metadata) => {
115
+ writes.push(id);
116
+ records[id] = { ...records[id], metadata };
117
+ },
118
+ });
119
+
120
+ expect(result.status).toBe('unavailable');
121
+ expect(writes).toEqual([]);
122
+ expect(records.ses_1.title).toBe('Stay');
123
+ expect(records.ses_1.metadata.openchamber.goal.id).toBe('g1');
124
+ });
125
+
126
+ it('keeps other sessions when one migration write fails', async () => {
127
+ const records = {
128
+ ses_ok: { id: 'ses_ok', title: 'Ok', metadata: {} },
129
+ ses_bad: { id: 'ses_bad', title: 'Bad', metadata: { openchamber: { llm: { purpose: 'keep' } } } },
130
+ ses_only_opencode: { id: 'ses_only_opencode', title: 'Stay', metadata: { fromOpenCode: true } },
131
+ };
132
+ const side = {
133
+ ses_ok: { openchamber: { goal: { id: 'g1' } } },
134
+ ses_bad: { openchamber: { assistant: { assistantID: 'ast_1' } } },
135
+ };
136
+
137
+ const result = await migrateLoadedSideStore({
138
+ loadResult: { ok: true, entries: side },
139
+ readSession: async (id) => records[id] ?? null,
140
+ writeMetadata: async (id, metadata) => {
141
+ if (id === 'ses_bad') throw new Error('write failed');
142
+ records[id] = { ...records[id], metadata };
143
+ },
144
+ });
145
+
146
+ expect(result.status).toBe('partial');
147
+ expect(result.migrated).toEqual(['ses_ok']);
148
+ expect(result.failed).toEqual([{ id: 'ses_bad', error: 'write failed' }]);
149
+ expect(records.ses_ok.metadata.openchamber.goal.id).toBe('g1');
150
+ expect(records.ses_ok.title).toBe('Ok');
151
+ expect(records.ses_bad.title).toBe('Bad');
152
+ expect(records.ses_bad.metadata.openchamber.llm.purpose).toBe('keep');
153
+ expect(records.ses_bad.metadata.openchamber.assistant).toBeUndefined();
154
+ expect(records.ses_only_opencode).toEqual({
155
+ id: 'ses_only_opencode',
156
+ title: 'Stay',
157
+ metadata: { fromOpenCode: true },
158
+ });
159
+ expect(side.ses_bad.openchamber.assistant.assistantID).toBe('ast_1');
160
+ });
161
+
162
+ it('fills side-store gaps without letting an empty side row wipe OpenCode fields', () => {
163
+ expect(mergeOwnedMetadata(
164
+ { openchamber: { goal: { status: 'paused' } }, fromOpenCode: true },
165
+ { openchamber: { goal: { id: 'g1' }, archive: { archivedAt: 4 } } },
166
+ )).toEqual({
167
+ fromOpenCode: true,
168
+ openchamber: { goal: { id: 'g1', status: 'paused' }, archive: { archivedAt: 4 } },
169
+ });
170
+ expect(mergeOwnedMetadata({ keep: true }, {})).toEqual({ keep: true });
171
+ expect(mergeOwnedMetadata({ keep: true }, null)).toEqual({ keep: true });
172
+ });
173
+
174
+ it('throws when the installed client has no session.update instead of writing nothing', async () => {
175
+ await expect(writeSessionRecordViaClient({ session: {} }, {
176
+ sessionID: 'ses_1',
177
+ metadata: { openchamber: { archive: { archivedAt: 1 } } },
178
+ })).rejects.toThrow(/session\.update is unavailable/);
179
+ });
180
+
181
+ it('writes metadata through session.update without sending title', async () => {
182
+ const update = vi.fn(async () => undefined);
183
+ await writeSessionRecordViaClient({ session: { update } }, {
184
+ sessionID: 'ses_1',
185
+ metadata: { openchamber: { archive: { archivedAt: 3 } } },
186
+ });
187
+ expect(update).toHaveBeenCalledWith({
188
+ sessionID: 'ses_1',
189
+ metadata: { openchamber: { archive: { archivedAt: 3 } } },
190
+ }, undefined);
191
+ expect(update.mock.calls[0][0].title).toBeUndefined();
192
+ });
193
+
194
+ it('reads a missing session as not-found and a transport failure as failure', async () => {
195
+ const missing = Object.assign(new Error('gone'), { status: 404 });
196
+ await expect(readSessionRecordViaClient({
197
+ session: { get: async () => { throw missing; } },
198
+ }, { sessionID: 'ses_1' })).resolves.toBeNull();
199
+
200
+ const down = Object.assign(new Error('down'), { status: 503 });
201
+ await expect(readSessionRecordViaClient({
202
+ session: { get: async () => { throw down; } },
203
+ }, { sessionID: 'ses_1' })).rejects.toThrow(/down/);
204
+ });
205
+
206
+ it('patches the session record over HTTP and does not turn a failed read into an empty session', async () => {
207
+ const calls = [];
208
+ const fetchFn = vi.fn(async (url, init) => {
209
+ calls.push({ url, method: init.method, body: init.body ? JSON.parse(init.body) : undefined });
210
+ if (init.method === 'PATCH' && String(url).includes('ses_ok')) {
211
+ return { ok: true, status: 204, json: async () => null };
212
+ }
213
+ if (String(url).includes('ses_missing')) {
214
+ return { ok: false, status: 404, json: async () => ({}) };
215
+ }
216
+ if (String(url).includes('ses_down')) {
217
+ return { ok: false, status: 503, json: async () => ({}) };
218
+ }
219
+ return {
220
+ ok: true,
221
+ status: 200,
222
+ json: async () => ({ id: 'ses_ok', title: 'Keep', metadata: { fromOpenCode: true } }),
223
+ };
224
+ });
225
+ const client = createHttpSessionRecordClient({
226
+ buildSessionUrl: (sessionID) => `http://opencode.local/api/session/${sessionID}`,
227
+ fetchFn,
228
+ });
229
+
230
+ await expect(client.read('ses_ok')).resolves.toMatchObject({ id: 'ses_ok', title: 'Keep' });
231
+ await expect(client.read('ses_missing')).resolves.toBeNull();
232
+ await expect(client.read('ses_down')).rejects.toThrow(/503/);
233
+ await client.write({
234
+ sessionID: 'ses_ok',
235
+ metadata: { openchamber: { archive: { archivedAt: 8 } } },
236
+ });
237
+
238
+ const patch = calls.find((call) => call.method === 'PATCH');
239
+ expect(patch.body).toEqual({ metadata: { openchamber: { archive: { archivedAt: 8 } } } });
240
+ expect(patch.body.title).toBeUndefined();
241
+ });
242
+ });