@openchambery/web 2.0.0-beta.3 → 2.0.0-beta.31

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 (292) hide show
  1. package/README.md +14 -0
  2. package/dist/assets/{AgentAvatar-B_JQoreg.js → AgentAvatar-O2x_1cFU.js} +1 -1
  3. package/dist/assets/AssistantView-D_vWSFzf.js +3 -0
  4. package/dist/assets/{DiagramView-BNnda8qO.js → DiagramView-B_hiA-Tg.js} +1 -1
  5. package/dist/assets/{FadeInOnReveal-VYQTSAkr.js → FadeInOnReveal-CMBd5BTd.js} +1 -1
  6. package/dist/assets/Icon-BN87CyS4.js +1 -0
  7. package/dist/assets/MarkdownRenderer--4f1RVU1.js +2 -0
  8. package/dist/assets/{MarkdownRendererImpl-DwrHFGUp.js → MarkdownRendererImpl-Blh7LZm7.js} +3 -3
  9. package/dist/assets/MarkstreamRendererImpl-CstRN3R1.js +3 -0
  10. package/dist/assets/{MobileDetailNavigation-BXr6AGLT.js → MobileDetailNavigation-DR6wUqeI.js} +1 -1
  11. package/dist/assets/{MobileShareBridge-D7OE9OP_.js → MobileShareBridge-CTFAmi5i.js} +3 -3
  12. package/dist/assets/MobileSurface-B5XmnUTI.js +2 -0
  13. package/dist/assets/{MultiRunWindow-DF3iIFCd.js → MultiRunWindow-ccv_K6Mt.js} +1 -1
  14. package/dist/assets/{OnboardingScreen-BjHmlwlw.js → OnboardingScreen-BLjZIBOs.js} +1 -1
  15. package/dist/assets/{OverlayScrollbar-DLtWO_Gp.js → OverlayScrollbar-CoZ4wtau.js} +1 -1
  16. package/dist/assets/SettingsView-LYoifZgH.js +25 -0
  17. package/dist/assets/{SettingsWindow-BI5swspb.js → SettingsWindow-CnDiFgGl.js} +1 -1
  18. package/dist/assets/TerminalView-DRhXa_u9.js +8 -0
  19. package/dist/assets/{ToolOutputDialog-BEWhBcRd.js → ToolOutputDialog-cQm6YPbu.js} +1 -1
  20. package/dist/assets/appThemeRegistry-DvJG4vb9.js +1 -0
  21. package/dist/assets/{assistant-attachment-cache-C0aRnFkj.js → assistant-attachment-cache-sG8YQsVs.js} +1 -1
  22. package/dist/assets/{button-DKuz3L8l.js → button-Ck8ipzBm.js} +1 -1
  23. package/dist/assets/collapsible-nU99prkD.js +1 -0
  24. package/dist/assets/context-menu-DMzZN1oi.js +1 -0
  25. package/dist/assets/{desktopHosts-CoDkOnyS.js → desktopHosts-DuftEj8B.js} +1 -1
  26. package/dist/assets/desktopRelayRestore-g378BdZf.js +1 -0
  27. package/dist/assets/{diagram-D7T8HQgj.js → diagram-CvO4P8ds.js} +1 -1
  28. package/dist/assets/dialog-ByGUkS3c.js +1 -0
  29. package/dist/assets/dropdown-menu.styles-ayLWk2gf.js +1 -0
  30. package/dist/assets/es-DCjl24dV.js +15 -0
  31. package/dist/assets/fileMentionTouchSelection-B4QTB6Tq.js +2 -0
  32. package/dist/assets/{fileQueries-JrpcsOCs.js → fileQueries-DuF89nFO.js} +1 -1
  33. package/dist/assets/fr-DfJ5j0t5.js +13 -0
  34. package/dist/assets/{gitApi-CM42qUed.js → gitApi-ChE0ftqF.js} +3 -3
  35. package/dist/assets/{imageSource-CIl0FL10.js → imageSource-BpUsiwFd.js} +1 -1
  36. package/dist/assets/{input-CzQy8UhV.js → input-CXhFmHcq.js} +1 -1
  37. package/dist/assets/insertionBoundaries-BP5YmZFb.js +1 -0
  38. package/dist/assets/ja-B_9_w2Xf.js +15 -0
  39. package/dist/assets/ko-DlGbo3kV.js +15 -0
  40. package/dist/assets/main-B0y8N4sq.js +2 -0
  41. package/dist/assets/main-D83L0SQU.js +169 -0
  42. package/dist/assets/{markdownHeightCache-Cj1I5NDw.js → markdownHeightCache-g_UsWzbA.js} +1 -1
  43. package/dist/assets/markdownTheme-DM5h1i18.js +1 -0
  44. package/dist/assets/{mcpQueries-D5mQymgM.js → mcpQueries-BtSRn19d.js} +1 -1
  45. package/dist/assets/miniChat-s8g2Otyu.js +2 -0
  46. package/dist/assets/mobile-B6toR_NR.js +2 -0
  47. package/dist/assets/multirun-BqZ3MZrK.js +3 -0
  48. package/dist/assets/persistence-Dnj1QOdX.js +3 -0
  49. package/dist/assets/pl-DjUsS8Ei.js +15 -0
  50. package/dist/assets/platform-X3Y3GyM5.js +6 -0
  51. package/dist/assets/projectMeta-DKjtarC_.js +1 -0
  52. package/dist/assets/pt-BR-B8sV7qo1.js +15 -0
  53. package/dist/assets/{relay-image-stream-DM7atqJs.js → relay-image-stream-D2XooXKZ.js} +1 -1
  54. package/dist/assets/renderElectronMiniChatApp-Bk98yyKC.js +2 -0
  55. package/dist/assets/renderMobileApp-B_qgjDlv.js +28 -0
  56. package/dist/assets/runtime-fetch-CiqQd__Y.js +1 -0
  57. package/dist/assets/runtime-switch-BJM2k_uH.js +1 -0
  58. package/dist/assets/runtimeConfig-BMSsd_sn.js +2 -0
  59. package/dist/assets/runtimeEndpointReset-DsuP5FPT.css +1 -0
  60. package/dist/assets/runtimeEndpointReset-HsKwHFWr.js +148 -0
  61. package/dist/assets/sessionChangeSummary-C8OSNmpS.js +2 -0
  62. package/dist/assets/{sessionLookup-myHe5ltu.js → sessionLookup-DuNsPLgr.js} +1 -1
  63. package/dist/assets/{sortable-tabs-strip-BQtmHx9r.js → sortable-tabs-strip-T-dk_L69.js} +1 -1
  64. package/dist/assets/src-BmSX_CVj.css +2 -0
  65. package/dist/assets/store-Cuktref-.js +15 -0
  66. package/dist/assets/{terminalApi-BxcdiduX.js → terminalApi-C_4joKUG.js} +1 -1
  67. package/dist/assets/textarea-biAqiqnJ.js +1 -0
  68. package/dist/assets/{tooltip-Cx5gTbdY.js → tooltip-BKL7J5hx.js} +1 -1
  69. package/dist/assets/uk-Bh7HXyLp.js +15 -0
  70. package/dist/assets/{url-CDHnjlsm.js → url-DNRMq_0R.js} +1 -1
  71. package/dist/assets/{useAppFontEffects-CNeo0aZs.js → useAppFontEffects-oEf92G32.js} +57 -52
  72. package/dist/assets/{useAssistantUIStore-asNG6K7n.js → useAssistantUIStore-DBwfg9nX.js} +1 -1
  73. package/dist/assets/useDesktopWindowControlsLayout-CD-czj1j.js +1 -0
  74. package/dist/assets/{useEffectiveDirectory-B4LvR8Gt.js → useEffectiveDirectory-BmrftoaF.js} +1 -1
  75. package/dist/assets/{useFontPreferences-CUnQ4TVm.js → useFontPreferences-CMrbTA0r.js} +1 -1
  76. package/dist/assets/useMobileNavigationStore-Bzu_5Aa7.js +415 -0
  77. package/dist/assets/{useRuntimeAPIs-DwLtO0c_.js → useRuntimeAPIs-DSLXJybW.js} +1 -1
  78. package/dist/assets/useSessionAutoCleanup-CHvstjnp.js +2 -0
  79. package/dist/assets/{useThemeSystem-BlNUWH47.js → useThemeSystem-DMzWVJaW.js} +1 -1
  80. package/dist/assets/useUIStore-Dfx6TKvi.js +1 -0
  81. package/dist/assets/{useWorkerHighlightedLines-CJZbJDW3.js → useWorkerHighlightedLines-Cm51EGlD.js} +1 -1
  82. package/dist/assets/{vendor-base-ui-CDjyofeD.js → vendor-base-ui-CnHfUmDN.js} +1 -1
  83. package/dist/assets/{vendor-cmdk-BFH0UpXX.js → vendor-cmdk-CeAGfrLo.js} +1 -1
  84. package/dist/assets/{vendor-dnd-kit-accessibility-BrR3AKvM.js → vendor-dnd-kit-accessibility-BY5LhhUB.js} +1 -1
  85. package/dist/assets/{vendor-dnd-kit-core-CNfdvk6H.js → vendor-dnd-kit-core-CvWxUPdb.js} +1 -1
  86. package/dist/assets/{vendor-dnd-kit-sortable-DQUaTeGj.js → vendor-dnd-kit-sortable-hHdP9lLb.js} +1 -1
  87. package/dist/assets/{vendor-framer-motion-4dogsUk3.js → vendor-framer-motion-DFy_BSVd.js} +1 -1
  88. package/dist/assets/{vendor-legendapp-list-BU7MdPZR.js → vendor-legendapp-list-WE32lwNk.js} +1 -1
  89. package/dist/assets/{vendor-markstream-react-qHC2FykU.js → vendor-markstream-react-DX55XLGO.js} +3 -3
  90. package/dist/assets/vendor-opencode-client-MF2GBrFx.js +12 -0
  91. package/dist/assets/{vendor-pierre-diffs-CUfqC003.js → vendor-pierre-diffs-DX7XTPG3.js} +1 -1
  92. package/dist/assets/{vendor-react-C94A0AHl.js → vendor-react-DLPElpoQ.js} +1 -1
  93. package/dist/assets/{vendor-react-drawio-FVlWqKVE.js → vendor-react-drawio-ECtEwvbc.js} +1 -1
  94. package/dist/assets/{vendor-reactuses-core-C2A3EjUi.js → vendor-reactuses-core-Dq5ocTF8.js} +1 -1
  95. package/dist/assets/{vendor-sonner-DNqHmUQs.js → vendor-sonner-DVzioF3d.js} +1 -1
  96. package/dist/assets/{vendor-stream-diffs-CwbKsmlv.js → vendor-stream-diffs-DWy3xSmp.js} +2 -2
  97. package/dist/assets/{vendor-tanstack-react-query-D0iFK57C.js → vendor-tanstack-react-query-D7pys_c9.js} +1 -1
  98. package/dist/assets/{vendor-tanstack-react-virtual-B2kV6cnT.js → vendor-tanstack-react-virtual-tlDgH6PG.js} +1 -1
  99. package/dist/assets/{vendor-zustand-BqLKf6ZY.js → vendor-zustand-DzRqcymP.js} +2 -2
  100. package/dist/assets/zh-CN-Uzia0gk4.js +15 -0
  101. package/dist/assets/zh-TW-CiI7DLdB.js +15 -0
  102. package/dist/index.html +15 -15
  103. package/dist/mini-chat.html +15 -15
  104. package/dist/mobile.html +15 -15
  105. package/package.json +3 -2
  106. package/server/index.js +46 -27
  107. package/server/lib/browser-provider/DOCUMENTATION.md +141 -0
  108. package/server/lib/browser-provider/contract.js +312 -0
  109. package/server/lib/browser-provider/host.js +421 -0
  110. package/server/lib/browser-provider/host.test.js +304 -0
  111. package/server/lib/browser-provider/routes.js +122 -0
  112. package/server/lib/browser-provider/routes.test.js +120 -0
  113. package/server/lib/browser-provider/selection-store.js +43 -0
  114. package/server/lib/browser-provider/selection-store.test.js +35 -0
  115. package/server/lib/event-stream/DOCUMENTATION.md +3 -1
  116. package/server/lib/event-stream/reasoning-projection.js +16 -1
  117. package/server/lib/event-stream/reasoning-projection.test.js +15 -0
  118. package/server/lib/git/DOCUMENTATION.md +1 -0
  119. package/server/lib/git/routes.js +7 -4
  120. package/server/lib/git/routes.test.js +34 -0
  121. package/server/lib/git/service.js +3 -1
  122. package/server/lib/llm/DOCUMENTATION.md +6 -4
  123. package/server/lib/llm/generate.js +8 -9
  124. package/server/lib/llm/generate.test.js +33 -6
  125. package/server/lib/message-queue/service.js +1 -1
  126. package/server/lib/notifications/DOCUMENTATION.md +3 -1
  127. package/server/lib/notifications/runtime.js +128 -31
  128. package/server/lib/notifications/runtime.test.js +103 -0
  129. package/server/lib/notifications/template-runtime.js +130 -38
  130. package/server/lib/notifications/template-runtime.test.js +96 -0
  131. package/server/lib/opencode/DOCUMENTATION.md +36 -19
  132. package/server/lib/opencode/agents.js +13 -22
  133. package/server/lib/opencode/auth-state-runtime.js +3 -3
  134. package/server/lib/opencode/auth-state-runtime.test.js +59 -0
  135. package/server/lib/opencode/auth.d.ts +13 -0
  136. package/server/lib/opencode/auth.js +74 -64
  137. package/server/lib/opencode/auth.test.js +57 -0
  138. package/server/lib/opencode/commands.js +1 -1
  139. package/server/lib/opencode/config-entities-layering.test.js +72 -0
  140. package/server/lib/opencode/config-entity-routes.js +16 -124
  141. package/server/lib/opencode/config-entity-routes.test.js +48 -11
  142. package/server/lib/opencode/config-files.d.ts +17 -0
  143. package/server/lib/opencode/config-files.js +187 -0
  144. package/server/lib/opencode/config-files.test.js +201 -0
  145. package/server/lib/opencode/core-routes.js +0 -1
  146. package/server/lib/opencode/core-routes.test.js +0 -15
  147. package/server/lib/opencode/credential-boundary.d.ts +1 -0
  148. package/server/lib/opencode/credential-boundary.js +11 -0
  149. package/server/lib/opencode/ensure-cli.d.ts +8 -0
  150. package/server/lib/opencode/ensure-cli.js +4 -1
  151. package/server/lib/opencode/env-config.js +4 -1
  152. package/server/lib/opencode/env-config.test.js +28 -0
  153. package/server/lib/opencode/env-runtime.test.js +18 -1
  154. package/server/lib/opencode/feature-routes-runtime.js +19 -7
  155. package/server/lib/opencode/feature-routes-runtime.test.js +7 -0
  156. package/server/lib/opencode/hmr-state-runtime.js +4 -4
  157. package/server/lib/opencode/lifecycle.js +1 -49
  158. package/server/lib/opencode/lifecycle.test.js +30 -4
  159. package/server/lib/opencode/location-release.d.ts +8 -0
  160. package/server/lib/opencode/location-release.js +32 -0
  161. package/server/lib/opencode/location-release.test.js +43 -0
  162. package/server/lib/opencode/mcp.js +1 -1
  163. package/server/lib/opencode/opencode2-pin.js +2 -1
  164. package/server/lib/opencode/owned-runtime-upgrade.test.js +5 -5
  165. package/server/lib/opencode/plugin-routes.test.js +4 -2
  166. package/server/lib/opencode/plugins.js +12 -60
  167. package/server/lib/opencode/plugins.test.js +5 -3
  168. package/server/lib/opencode/providers.js +6 -3
  169. package/server/lib/opencode/proxy.js +13 -1
  170. package/server/lib/opencode/proxy.test.js +31 -3
  171. package/server/lib/opencode/remote-upgrade.integration.test.js +66 -0
  172. package/server/lib/opencode/routes.js +40 -8
  173. package/server/lib/opencode/routes.test.js +51 -10
  174. package/server/lib/opencode/runtime-contract.js +3 -2
  175. package/server/lib/opencode/runtime-contract.test.js +12 -12
  176. package/server/lib/opencode/shared.js +6 -175
  177. package/server/lib/opencode/update-discovery.js +39 -0
  178. package/server/lib/opencode/update-discovery.test.js +39 -0
  179. package/server/lib/opencode/upgrade-screen.d.ts +30 -0
  180. package/server/lib/opencode/upgrade-screen.js +292 -0
  181. package/server/lib/opencode/upgrade-screen.test.js +196 -0
  182. package/server/lib/opencode/version-alignment.test.js +40 -0
  183. package/server/lib/question-auto-delegate/DOCUMENTATION.md +21 -6
  184. package/server/lib/question-auto-delegate/core.d.ts +5 -1
  185. package/server/lib/question-auto-delegate/core.js +21 -13
  186. package/server/lib/question-auto-delegate/form-io.d.ts +6 -0
  187. package/server/lib/question-auto-delegate/form-io.js +39 -0
  188. package/server/lib/question-auto-delegate/runtime.js +4 -24
  189. package/server/lib/question-auto-delegate/runtime.test.js +138 -0
  190. package/server/lib/quota/DOCUMENTATION.md +9 -0
  191. package/server/lib/quota/provider-credentials.test.js +29 -0
  192. package/server/lib/quota/providers/claude.js +4 -4
  193. package/server/lib/quota/providers/codex.js +4 -4
  194. package/server/lib/quota/providers/codex.test.js +1 -1
  195. package/server/lib/quota/providers/copilot.js +5 -5
  196. package/server/lib/quota/providers/google/auth.js +3 -3
  197. package/server/lib/quota/providers/google/index.js +2 -2
  198. package/server/lib/quota/providers/index.js +3 -7
  199. package/server/lib/quota/providers/index.test.js +2 -2
  200. package/server/lib/quota/providers/kimi.js +4 -4
  201. package/server/lib/quota/providers/minimax-shared.js +4 -4
  202. package/server/lib/quota/providers/nanogpt.js +4 -4
  203. package/server/lib/quota/providers/openai.js +4 -4
  204. package/server/lib/quota/providers/openrouter.js +4 -4
  205. package/server/lib/quota/providers/wafer.js +4 -4
  206. package/server/lib/quota/providers/zai.js +4 -4
  207. package/server/lib/quota/providers/zhipuai-coding-plan.js +6 -20
  208. package/server/lib/quota/routes.js +1 -1
  209. package/server/lib/relay/DOCUMENTATION.md +3 -1
  210. package/server/lib/relay/service.js +1 -0
  211. package/server/lib/relay/service.test.js +13 -0
  212. package/server/lib/scheduled-tasks/DOCUMENTATION.md +8 -1
  213. package/server/lib/scheduled-tasks/runtime.js +12 -4
  214. package/server/lib/scheduled-tasks/runtime.test.js +81 -6
  215. package/server/lib/session-goal/DOCUMENTATION.md +11 -0
  216. package/server/lib/session-goal/runtime.js +4 -4
  217. package/server/lib/session-goal/runtime.test.js +32 -3
  218. package/server/lib/session-index/DOCUMENTATION.md +7 -0
  219. package/server/lib/session-index/event-ingest.js +13 -0
  220. package/server/lib/session-index/event-ingest.test.js +7 -0
  221. package/server/lib/session-index/service.js +45 -0
  222. package/server/lib/session-index/service.test.js +47 -0
  223. package/server/lib/session-metadata/DOCUMENTATION.md +33 -17
  224. package/server/lib/session-metadata/opencode-session-record.d.ts +57 -0
  225. package/server/lib/session-metadata/opencode-session-record.js +316 -0
  226. package/server/lib/session-metadata/opencode-session-record.test.js +242 -0
  227. package/server/lib/session-metadata/routes.js +5 -1
  228. package/server/lib/session-metadata/session-archive.js +1 -1
  229. package/server/lib/session-metadata/session-metadata-store.d.ts +2 -0
  230. package/server/lib/session-metadata/session-metadata-store.js +63 -16
  231. package/server/lib/session-metadata/session-metadata-store.test.js +23 -0
  232. package/server/lib/session-metadata/session-projection.d.ts +1 -0
  233. package/server/lib/session-metadata/session-projection.js +49 -2
  234. package/server/lib/session-metadata/session-projection.test.js +35 -0
  235. package/server/lib/session-title/DOCUMENTATION.md +52 -14
  236. package/server/lib/session-title/native-runtime.test.js +59 -5
  237. package/server/lib/session-title/runtime.js +65 -19
  238. package/server/lib/session-title/session-access.js +108 -3
  239. package/server/lib/session-title/session-access.test.js +125 -7
  240. package/server/lib/tts/DOCUMENTATION.md +7 -5
  241. package/server/lib/tts/credential-source.test.js +23 -0
  242. package/server/lib/tts/routes.js +2 -2
  243. package/server/lib/tts/service.js +16 -37
  244. package/dist/assets/AssistantView-ClQAfGOT.js +0 -3
  245. package/dist/assets/Icon-BiEQEcih.js +0 -1
  246. package/dist/assets/MarkdownRenderer-CJHSsWin.js +0 -2
  247. package/dist/assets/MarkstreamRendererImpl-CSnbQMu3.js +0 -3
  248. package/dist/assets/MobileSurface-D2jUkX9l.js +0 -2
  249. package/dist/assets/SettingsView-BRxELjow.js +0 -25
  250. package/dist/assets/TerminalView-BU3x8-fr.js +0 -8
  251. package/dist/assets/appThemeRegistry-4MR0F7Jz.js +0 -1
  252. package/dist/assets/collapsible-DXbDynN4.js +0 -3
  253. package/dist/assets/context-menu-CqolsLCm.js +0 -1
  254. package/dist/assets/desktopRelayRestore-DDlVKyOE.js +0 -1
  255. package/dist/assets/dialog-DQYl3W_o.js +0 -1
  256. package/dist/assets/dropdown-menu.styles-Bzo6L3FH.js +0 -1
  257. package/dist/assets/es-CN3E_zXj.js +0 -15
  258. package/dist/assets/fileMentionTouchSelection-BZ6ovBYH.js +0 -4
  259. package/dist/assets/fr-9zZvQZD2.js +0 -13
  260. package/dist/assets/insertionBoundaries-CtkZ9720.js +0 -1
  261. package/dist/assets/ja-Dnjk_82N.js +0 -15
  262. package/dist/assets/ko-BxCEE99C.js +0 -15
  263. package/dist/assets/main-BILgBI3B.js +0 -169
  264. package/dist/assets/main-BeveiW6F.js +0 -2
  265. package/dist/assets/markdownTheme-CKac3wKC.js +0 -1
  266. package/dist/assets/miniChat-BgrzM3u7.js +0 -2
  267. package/dist/assets/mobile-DtYoQE8C.js +0 -2
  268. package/dist/assets/multirun-jy8dEI96.js +0 -4
  269. package/dist/assets/persistence-C9LQpiOS.js +0 -3
  270. package/dist/assets/pl-CNNsWnPM.js +0 -15
  271. package/dist/assets/platform-BQghHRfp.js +0 -6
  272. package/dist/assets/pt-BR-BZB2DJMp.js +0 -15
  273. package/dist/assets/renderElectronMiniChatApp-BGrrmeME.js +0 -2
  274. package/dist/assets/renderMobileApp-u3c4GtFC.js +0 -28
  275. package/dist/assets/runtime-fetch-zIalUnbb.js +0 -1
  276. package/dist/assets/runtime-switch-LvWDU-SL.js +0 -1
  277. package/dist/assets/runtimeConfig-BIGBRGQI.js +0 -2
  278. package/dist/assets/runtimeEndpointReset-D2savkuw.js +0 -147
  279. package/dist/assets/sessionChangeSummary-eIbpSfrO.js +0 -2
  280. package/dist/assets/src-CLN7Z-zx.css +0 -2
  281. package/dist/assets/store-DLCiZaA6.js +0 -15
  282. package/dist/assets/textarea-CakKvqFw.js +0 -1
  283. package/dist/assets/uk-rwQrxeYM.js +0 -15
  284. package/dist/assets/useDesktopWindowControlsLayout-DBJ56qIo.js +0 -1
  285. package/dist/assets/useMobileNavigationStore-ZHJPxlHt.js +0 -414
  286. package/dist/assets/useSessionAutoCleanup-BIVmrnYU.js +0 -2
  287. package/dist/assets/useUIStore-CQYKJnrg.js +0 -1
  288. package/dist/assets/vendor-opencode-client-BOe2qpQb.js +0 -12
  289. package/dist/assets/zh-CN-BvmBDnkD.js +0 -15
  290. package/dist/assets/zh-TW-9wcHTX0y.js +0 -15
  291. package/server/lib/opencode/snippets.js +0 -242
  292. package/server/lib/opencode/snippets.test.js +0 -97
@@ -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
+ });
@@ -83,13 +83,17 @@ export const registerSessionMetadataRoutes = (app, deps = {}) => {
83
83
  },
84
84
  },
85
85
  };
86
+ }, {
87
+ directory: asNonEmptyString(body.directory) || undefined,
86
88
  });
87
89
  if (!result?.committed) {
88
90
  throw new Error(result?.reason || 'session metadata mutate was rejected');
89
91
  }
90
92
  metadata = result.metadata;
91
93
  } else {
92
- metadata = await sessionMetadataStore.setSessionMetadata(sessionId, patch);
94
+ metadata = await sessionMetadataStore.setSessionMetadata(sessionId, patch, {
95
+ directory: asNonEmptyString(body.directory) || undefined,
96
+ });
93
97
  }
94
98
  const directory = asNonEmptyString(body.directory) || '';
95
99
  if (typeof onMetadataWritten === 'function') {
@@ -451,7 +451,7 @@ export const createSessionArchiveService = (deps = {}) => {
451
451
  const metadata = await sessionMetadataStore.setSessionMetadata(
452
452
  sessionID,
453
453
  { openchamber: { archive: { archivedAt } } },
454
- { allowArchive: true },
454
+ { allowArchive: true, directory: upstreamDirectory || directoryHint },
455
455
  );
456
456
 
457
457
  // New Host mutation wins: bump generation and cancel older repair timers
@@ -33,4 +33,6 @@ export function createSessionMetadataStore(options: {
33
33
  fsPromises?: typeof import('node:fs').promises;
34
34
  path?: typeof import('node:path');
35
35
  now?: () => number;
36
+ recordReader?: (sessionID: string, directory?: string | null) => Promise<object | null>;
37
+ recordWriter?: (sessionID: string, metadata: object, directory?: string | null) => Promise<void>;
36
38
  }): SessionMetadataStore;
@@ -1,26 +1,28 @@
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.
14
16
  *
15
17
  * Mutations are fully serialized. Committed `entries` update only after a
16
18
  * successful persist so concurrent readers never observe uncommitted drafts.
17
- * A failed or corrupt file read is never empty success: the store stays
18
- * unwritable and reports the error instead of overwriting unknown disk state.
19
19
  */
20
20
 
21
21
  import fsDefault from 'node:fs';
22
22
  import pathDefault from 'node:path';
23
23
 
24
+ import { mergeOwnedMetadata } from './opencode-session-record.js';
25
+
24
26
  const METADATA_FILE_NAME = 'sessions-metadata.json';
25
27
  const HOST_ARCHIVE_KEY = 'archive';
26
28
 
@@ -103,13 +105,22 @@ const applyReservedArchiveProtection = (previous, patch, merged, { allowArchive
103
105
  * @param {typeof fsDefault.promises} [options.fsPromises]
104
106
  * @param {typeof pathDefault} [options.path]
105
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.
106
112
  */
107
113
  export const createSessionMetadataStore = ({
108
114
  dataDir,
109
115
  fsPromises = fsDefault.promises,
110
116
  path = pathDefault,
111
117
  now = Date.now,
118
+ recordReader = null,
119
+ recordWriter = null,
112
120
  }) => {
121
+ if (typeof recordWriter === 'function' && typeof recordReader !== 'function') {
122
+ throw new Error('session record writer requires a record reader');
123
+ }
113
124
  const filePath = path.join(dataDir, METADATA_FILE_NAME);
114
125
 
115
126
  /** sessionID → metadata. Committed only — never holds an in-flight draft. */
@@ -218,11 +229,27 @@ export const createSessionMetadataStore = ({
218
229
  return next;
219
230
  };
220
231
 
221
- 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 = {}) => {
222
245
  const id = asNonEmptyString(sessionID);
223
246
  if (!id) return {};
224
247
  await ensureReadable();
225
- return entries.get(id) ?? {};
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);
226
253
  };
227
254
 
228
255
  const has = async (sessionID) => {
@@ -243,16 +270,36 @@ export const createSessionMetadataStore = ({
243
270
  */
244
271
  const commitMergedPatch = async (id, previous, patch, options = {}) => {
245
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
+ }
246
289
  const effectivePatch = allowArchive ? patch : stripArchiveFromMetadataPatch(patch);
247
290
 
248
- let merged = mergeMetadataPatch(previous, effectivePatch);
249
- merged = applyReservedArchiveProtection(previous, patch, merged, { allowArchive });
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
+ }
250
297
 
251
298
  const nextMap = new Map(entries);
252
299
  if (Object.keys(merged).length === 0) nextMap.delete(id);
253
300
  else nextMap.set(id, merged);
254
301
 
255
- // Persist draft snapshot first; publish to committed entries only on success.
302
+ // OpenCode write already landed. Publish the local cache only after its persist succeeds.
256
303
  await persistMap(nextMap);
257
304
 
258
305
  if (Object.keys(merged).length === 0) entries.delete(id);
@@ -53,6 +53,29 @@ describe('mergeMetadataPatch', () => {
53
53
  });
54
54
 
55
55
  describe('createSessionMetadataStore', () => {
56
+ it('round-trips title authority through the OpenCode record and restart without losing neighboring metadata', async () => {
57
+ const dataDir = makeDataDir();
58
+ const record = { id: 'ses_1', title: 'Native', metadata: { plugin: { retained: true } } };
59
+ const authority = { sessionID: 'ses_1', title: 'Chosen', revision: 'r1', source: 'summary' };
60
+ const deps = {
61
+ dataDir,
62
+ recordReader: async () => structuredClone(record),
63
+ recordWriter: async (_id, metadata) => { record.metadata = structuredClone(metadata); },
64
+ };
65
+ const store = createSessionMetadataStore(deps);
66
+ await store.mutateSessionMetadata('ses_1', () => ({ ok: true, patch: {
67
+ openchamber: { titleAuthority: authority },
68
+ } }));
69
+ expect(record.metadata).toEqual({ plugin: { retained: true }, openchamber: { titleAuthority: authority } });
70
+ const reopened = createSessionMetadataStore(deps);
71
+ expect((await reopened.get('ses_1')).openchamber.titleAuthority).toEqual(authority);
72
+ expect((await reopened.getAll()).ses_1.openchamber.titleAuthority).toEqual(authority);
73
+ const stale = await reopened.mutateSessionMetadata('ses_1', (metadata) => ({
74
+ ok: metadata.openchamber.titleAuthority.revision === 'old', patch: { openchamber: { titleAuthority: null } },
75
+ }));
76
+ expect(stale.committed).toBe(false);
77
+ expect(record.metadata.openchamber.titleAuthority).toEqual(authority);
78
+ });
56
79
  it('starts empty when the file does not exist', async () => {
57
80
  const store = createSessionMetadataStore({ dataDir: makeDataDir() });
58
81
  await expect(store.getAll()).resolves.toEqual({});
@@ -39,3 +39,4 @@ export function projectSessionLifecyclePayload(
39
39
  readHostMetadata: (sessionId: string) => unknown,
40
40
  options?: { hostReady?: boolean },
41
41
  ): unknown;
42
+ export function readSessionTitleAuthority(sessionID: string, metadata: unknown): { sessionID: string; title: string; revision: string; source?: string } | null;
@@ -13,6 +13,14 @@
13
13
 
14
14
  const isPlainObject = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
15
15
 
16
+ export const readSessionTitleAuthority = (sessionID, metadata) => {
17
+ const authority = metadata?.openchamber?.titleAuthority;
18
+ return typeof sessionID === 'string' && sessionID.length > 0 && authority?.sessionID === sessionID
19
+ && typeof authority.title === 'string' && authority.title.trim()
20
+ && typeof authority.revision === 'string' && authority.revision
21
+ ? authority : null;
22
+ };
23
+
16
24
  /** RFC 7386 merge (local copy — avoid circular import with the metadata store). */
17
25
  const mergeMetadataPatch = (current, patch) => {
18
26
  const base = isPlainObject(current) ? { ...current } : {};
@@ -33,6 +41,7 @@ const SESSION_LIFECYCLE_BASE = new Set([
33
41
  'session.created',
34
42
  'session.updated',
35
43
  'session.deleted',
44
+ 'session.renamed',
36
45
  ]);
37
46
 
38
47
  /**
@@ -156,8 +165,10 @@ export const projectSessionWithHostMetadata = (session, hostMetadata) => {
156
165
  else timeBase.archived = projectedArchived;
157
166
 
158
167
  const directory = resolveSessionDirectory(session);
168
+ const titleAuthority = readSessionTitleAuthority(session.id, metadata);
159
169
  const next = {
160
170
  ...session,
171
+ ...(titleAuthority ? { title: titleAuthority.title } : {}),
161
172
  metadata: Object.keys(metadata).length > 0 ? metadata : (session.metadata ?? metadata),
162
173
  time: timeBase,
163
174
  ...(directory ? { directory } : {}),
@@ -168,6 +179,30 @@ export const projectSessionWithHostMetadata = (session, hostMetadata) => {
168
179
  return next;
169
180
  };
170
181
 
182
+ /**
183
+ * OpenCode record keys win. Side-store keys the record does not have yet are
184
+ * filled in. A missing or empty side row does not wipe the record.
185
+ *
186
+ * @param {unknown} openCodeMetadata
187
+ * @param {unknown} sideMetadata
188
+ */
189
+ export const mergeOwnedMetadata = (openCodeMetadata, sideMetadata) => {
190
+ const openCode = isPlainObject(openCodeMetadata) ? openCodeMetadata : {};
191
+ if (!isPlainObject(sideMetadata) || Object.keys(sideMetadata).length === 0) {
192
+ return { ...openCode };
193
+ }
194
+ return mergeMetadataPatch(sideMetadata, openCode);
195
+ };
196
+
197
+ const projectRecordMetadata = (session, sideMetadata) => {
198
+ const metadata = mergeOwnedMetadata(session.metadata, sideMetadata);
199
+ const hadMetadata = isPlainObject(session.metadata);
200
+ const withRecord = Object.keys(metadata).length === 0 && !hadMetadata
201
+ ? session
202
+ : { ...session, metadata };
203
+ return projectSessionWithHostMetadata(withRecord, null);
204
+ };
205
+
171
206
  /**
172
207
  * @param {object | null | undefined} session
173
208
  * @param {Record<string, object> | null | undefined} storedBySessionId
@@ -178,11 +213,20 @@ export const projectSessionWithStoredMap = (session, storedBySessionId) => {
178
213
  return projectSessionWithHostMetadata(session, null);
179
214
  }
180
215
  const host = storedBySessionId[session.id];
181
- return projectSessionWithHostMetadata(session, isPlainObject(host) ? host : null);
216
+ return projectRecordMetadata(session, isPlainObject(host) ? host : null);
182
217
  };
183
218
 
184
219
  const projectLifecycleInner = (payload, readHostMetadata) => {
185
220
  const baseType = normalizeSessionEventType(payload.type);
221
+ if (baseType === 'session.renamed') {
222
+ const data = payload.data ?? payload.properties;
223
+ if (typeof data?.sessionID !== 'string') return payload;
224
+ const authority = readSessionTitleAuthority(data?.sessionID, readHostMetadata?.(data?.sessionID));
225
+ if (!authority || data.title === authority.title) return payload;
226
+ return payload.data
227
+ ? { ...payload, data: { ...data, title: authority.title } }
228
+ : { ...payload, properties: { ...data, title: authority.title } };
229
+ }
186
230
  if (baseType !== 'session.created' && baseType !== 'session.updated') return payload;
187
231
 
188
232
  const info = extractSessionInfoFromPayload(payload);
@@ -196,7 +240,7 @@ const projectLifecycleInner = (payload, readHostMetadata) => {
196
240
  host = null;
197
241
  }
198
242
  }
199
- const projected = projectSessionWithHostMetadata(info, isPlainObject(host) ? host : null);
243
+ const projected = projectRecordMetadata(info, isPlainObject(host) ? host : null);
200
244
  if (projected === info) return payload;
201
245
 
202
246
  const next = { ...payload };
@@ -243,6 +287,9 @@ export const projectSessionLifecyclePayload = (payload, readHostMetadata, option
243
287
  }
244
288
 
245
289
  const type = typeof payload.type === 'string' ? payload.type : '';
290
+ if (type === 'session.renamed') {
291
+ return options.hostReady === false ? null : projectLifecycleInner(payload, readHostMetadata);
292
+ }
246
293
  if (options.hostReady === false && isSessionLifecycleEventType(type)) {
247
294
  return null;
248
295
  }
@@ -5,9 +5,44 @@ import {
5
5
  normalizeSessionEventType,
6
6
  projectSessionLifecyclePayload,
7
7
  resolveSessionDirectory,
8
+ projectSessionWithHostMetadata,
9
+ readSessionTitleAuthority,
8
10
  } from './session-projection.js';
9
11
 
10
12
  describe('session projection event shapes', () => {
13
+ const titleMetadata = { openchamber: { titleAuthority: {
14
+ sessionID: 'ses_owned', title: 'Chosen title', revision: 'r1', source: 'summary',
15
+ } } };
16
+
17
+ it('keeps the owned title in snapshots and native rename events regardless of upstream clocks', () => {
18
+ const upstream = { id: 'ses_owned', title: 'Native title', time: { updated: 20 } };
19
+ expect(projectSessionWithHostMetadata(upstream, titleMetadata).title).toBe('Chosen title');
20
+ for (const created of [1, 20, 10000]) {
21
+ const event = { type: 'session.renamed', created, data: { sessionID: 'ses_owned', title: 'Native title' } };
22
+ expect(projectSessionLifecyclePayload(event, () => titleMetadata)).toEqual({
23
+ ...event, data: { ...event.data, title: 'Chosen title' },
24
+ });
25
+ expect(event.data.title).toBe('Native title');
26
+ }
27
+ expect(projectSessionWithHostMetadata({ ...upstream, id: 'ses_fork' }, titleMetadata).title).toBe('Native title');
28
+ });
29
+
30
+ it('suppresses native rename while metadata is unknown, including wrapped replay', () => {
31
+ const payload = { directory: '/repo', payload: {
32
+ type: 'session.renamed', data: { sessionID: 'ses_owned', title: 'Native' },
33
+ } };
34
+ expect(projectSessionLifecyclePayload(payload, () => null, { hostReady: false })).toBeNull();
35
+ expect(projectSessionLifecyclePayload(payload, () => titleMetadata).payload.data.title).toBe('Chosen title');
36
+ });
37
+
38
+ it('ignores malformed or absent authority and rename identities', () => {
39
+ for (const metadata of [undefined, {}, { openchamber: { titleAuthority: {} } }]) {
40
+ expect(readSessionTitleAuthority(undefined, metadata)).toBeNull();
41
+ expect(readSessionTitleAuthority('ses_owned', metadata)).toBeNull();
42
+ }
43
+ const event = { type: 'session.renamed', data: {} };
44
+ expect(projectSessionLifecyclePayload(event, () => titleMetadata)).toBe(event);
45
+ });
11
46
  it('normalizes version suffixes', () => {
12
47
  expect(normalizeSessionEventType('session.updated.v2')).toBe('session.updated');
13
48
  expect(normalizeSessionEventType('session.created')).toBe('session.created');