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

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-CzTtVMnI.js} +1 -1
  7. package/dist/assets/AssistantView-Cg1qQvVe.js +3 -0
  8. package/dist/assets/{DiagramView-xuSJTnv7.js → DiagramView-hOi72Wdx.js} +1 -1
  9. package/dist/assets/{FadeInOnReveal-Ov0D96d0.js → FadeInOnReveal-DCqrQkkz.js} +1 -1
  10. package/dist/assets/Icon-B8fSTLdP.js +1 -0
  11. package/dist/assets/MarkdownRenderer-Cw74oVxV.js +2 -0
  12. package/dist/assets/{MarkdownRendererImpl-SKi4oG0c.js → MarkdownRendererImpl-YM7iRmzK.js} +3 -3
  13. package/dist/assets/MarkstreamRendererImpl-BDy_oGJQ.js +3 -0
  14. package/dist/assets/{MobileDetailNavigation-Bnx59U_J.js → MobileDetailNavigation-iX_hb_Xt.js} +1 -1
  15. package/dist/assets/{MobileShareBridge-BQlP4J-F.js → MobileShareBridge-CAMUupwR.js} +2 -2
  16. package/dist/assets/MobileSurface-CHbdPcLH.js +2 -0
  17. package/dist/assets/{MultiRunWindow-ChZGob0v.js → MultiRunWindow-Ci1ODFFX.js} +1 -1
  18. package/dist/assets/{OnboardingScreen-DtQJxZG-.js → OnboardingScreen-O4cGdz5p.js} +1 -1
  19. package/dist/assets/OverlayScrollbar-CRfGtcmG.js +2 -0
  20. package/dist/assets/SettingsView-Dg8BtTV1.js +25 -0
  21. package/dist/assets/{SettingsWindow-DfnsFa8y.js → SettingsWindow-B8d8qIQ7.js} +1 -1
  22. package/dist/assets/TerminalView-CMiw3s_c.js +8 -0
  23. package/dist/assets/{ToolOutputDialog-BejmjrRk.js → ToolOutputDialog-ByaQYRTp.js} +2 -2
  24. package/dist/assets/appThemeRegistry-BGGHio8T.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-DIIrsMjW.js} +1 -1
  27. package/dist/assets/{collapsible-BXRd5_Hr.js → collapsible-CnvUeYst.js} +1 -1
  28. package/dist/assets/{context-menu-jGtH6fR9.js → context-menu-BGBUpZEQ.js} +1 -1
  29. package/dist/assets/{desktopHosts-B1ixEylr.js → desktopHosts-JGFqhlaM.js} +1 -1
  30. package/dist/assets/{desktopRelayRestore-4JmFUw8r.js → desktopRelayRestore-CHEwrI93.js} +1 -1
  31. package/dist/assets/{diagram-DQf7Y_uR.js → diagram-rvn7_qBM.js} +1 -1
  32. package/dist/assets/{dialog-bfbvR4VH.js → dialog-CQ63sc9E.js} +1 -1
  33. package/dist/assets/es-Dsmc0A_3.js +15 -0
  34. package/dist/assets/fileMentionTouchSelection-BzzcWJCi.js +3 -0
  35. package/dist/assets/{fileQueries-DQc8EJUf.js → fileQueries-DN6gbLhM.js} +1 -1
  36. package/dist/assets/fr-C_ZlZDI2.js +13 -0
  37. package/dist/assets/gitApi-BF8vuoZc.js +10 -0
  38. package/dist/assets/{imageSource-BlAJJUEI.js → imageSource-BMztRu0c.js} +1 -1
  39. package/dist/assets/{input-8r6F_Fza.js → input-BVu-iykl.js} +1 -1
  40. package/dist/assets/insertionBoundaries-EcAfGT2D.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-CCE_4y5F.js → main-B7t9lavz.js} +2 -2
  44. package/dist/assets/main-D2BzpnUt.css +1 -0
  45. package/dist/assets/main-DWP-prNB.js +169 -0
  46. package/dist/assets/markdown-worker-bS5OgGO3.js +2 -0
  47. package/dist/assets/{markdownHeightCache-1Qo6y6IJ.js → markdownHeightCache-D9-B1wdY.js} +1 -1
  48. package/dist/assets/markdownTheme-DM5h1i18.js +1 -0
  49. package/dist/assets/mcpQueries-CfXrAkuU.js +1 -0
  50. package/dist/assets/miniChat-DgOrEQbN.js +2 -0
  51. package/dist/assets/mobile-Co_ZOHkZ.js +2 -0
  52. package/dist/assets/multirun-DelyUO6k.js +4 -0
  53. package/dist/assets/persistence-D0y7226-.js +3 -0
  54. package/dist/assets/pl-BCf5uLlK.js +15 -0
  55. package/dist/assets/platform-CurqO28D.js +6 -0
  56. package/dist/assets/projectMeta-CPElzJ0n.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-pOE93w86.js +2 -0
  60. package/dist/assets/renderMobileApp-F7ZSLcw8.js +28 -0
  61. package/dist/assets/runtime-fetch-Fs9ZV2V8.js +1 -0
  62. package/dist/assets/{runtimeConfig-BMvqa5we.js → runtimeConfig-DSS0KxG3.js} +1 -1
  63. package/dist/assets/runtimeEndpointReset-x2Wgx57R.js +149 -0
  64. package/dist/assets/sessionChangeSummary-BUGSYcHp.js +2 -0
  65. package/dist/assets/{sessionLookup-gh-hVRIc.js → sessionLookup-Bss8M20i.js} +1 -1
  66. package/dist/assets/{sortable-tabs-strip-CudoSgV4.js → sortable-tabs-strip-CE7W7lJ9.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-mDR2jXTo.js} +1 -1
  70. package/dist/assets/textarea-XD2oX_Ee.js +1 -0
  71. package/dist/assets/{tooltip-CQvpK-UV.js → tooltip-DYeQ_Hvy.js} +1 -1
  72. package/dist/assets/uk-BgFzz8m2.js +15 -0
  73. package/dist/assets/{url-_z7evzdK.js → url-CGbJbiJq.js} +1 -1
  74. package/dist/assets/{useAppFontEffects-Cpmpo7Wb.js → useAppFontEffects-BAWHZ-t0.js} +64 -45
  75. package/dist/assets/{useAssistantUIStore-CMutVh9B.js → useAssistantUIStore-Bz4tRflF.js} +1 -1
  76. package/dist/assets/useDesktopWindowControlsLayout-hN_nLb7T.js +1 -0
  77. package/dist/assets/useEffectiveDirectory-Do9DQ453.js +1 -0
  78. package/dist/assets/{useFontPreferences-DcbXQOkc.js → useFontPreferences-Bjx8zkGM.js} +1 -1
  79. package/dist/assets/useMobileNavigationStore-BsY_gi_K.js +414 -0
  80. package/dist/assets/useSessionAutoCleanup-C4g5KmDf.js +2 -0
  81. package/dist/assets/useUIStore-syVmbuDA.js +1 -0
  82. package/dist/assets/{useWorkerHighlightedLines-Bqn4AiBL.js → useWorkerHighlightedLines-B5QXxBok.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,55 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import {
3
+ LLM_SESSION_PURPOSE,
4
+ buildAssistantSessionMetadata,
5
+ buildLlmSessionMetadata,
6
+ buildScheduledTaskMetadata,
7
+ } from './system-session.js';
8
+
9
+ describe('system session metadata builders', () => {
10
+ it('builds scheduled-task isolation from a non-empty taskID', () => {
11
+ expect(buildScheduledTaskMetadata({
12
+ projectID: 'project-1',
13
+ taskID: 'task-1',
14
+ runID: 'run-1',
15
+ name: 'Morning Sync',
16
+ })).toEqual({
17
+ openchamber: {
18
+ scheduledTask: {
19
+ taskID: 'task-1',
20
+ projectID: 'project-1',
21
+ runID: 'run-1',
22
+ name: 'Morning Sync',
23
+ },
24
+ },
25
+ });
26
+ });
27
+
28
+ it('refuses scheduled-task metadata without taskID', () => {
29
+ expect(() => buildScheduledTaskMetadata({ projectID: 'project-1' })).toThrow(/taskID/);
30
+ });
31
+
32
+ it('builds llm isolation with the chat-completions purpose', () => {
33
+ expect(buildLlmSessionMetadata()).toEqual({
34
+ openchamber: { llm: { purpose: LLM_SESSION_PURPOSE } },
35
+ });
36
+ expect(buildLlmSessionMetadata(' ')).toEqual({
37
+ openchamber: { llm: { purpose: LLM_SESSION_PURPOSE } },
38
+ });
39
+ });
40
+
41
+ it('builds assistant isolation from a non-empty assistantID', () => {
42
+ expect(buildAssistantSessionMetadata({
43
+ assistantID: 'assistant_1',
44
+ name: 'Ops',
45
+ })).toEqual({
46
+ openchamber: {
47
+ assistant: { assistantID: 'assistant_1', name: 'Ops' },
48
+ },
49
+ });
50
+ });
51
+
52
+ it('refuses assistant metadata without assistantID', () => {
53
+ expect(() => buildAssistantSessionMetadata({ name: 'Ops' })).toThrow(/assistantID/);
54
+ });
55
+ });
@@ -5,20 +5,53 @@ conversation's **main subject** (overall feature / goal) with the small model
5
5
  (`lib/small-model`), then PATCHes `title` plus `metadata.openchamber.titleRefresh`.
6
6
 
7
7
  OpenCode only auto-titles once from the first user message
8
- (`SessionPrompt.ensureTitle`). This module auto-refreshes sparsely (first idle
9
- of a new session, first newly-sent reply on a fork) and on explicit smart-title
8
+ (`SessionPrompt.ensureTitle`). This module auto-refreshes sparsely (first user
9
+ admission of a new session, first newly-sent reply on a fork) and on explicit smart-title
10
10
  requests, naming the durable work being done — not the last wrap-up utterance
11
11
  like "commit and push". Background auto refreshes still respect a 5-minute
12
12
  throttle (`TITLE_THROTTLE_MS`) when a refresh is armed.
13
13
 
14
14
  ## Flow
15
15
 
16
+ ### OpenCode 2 smart-title boundary
17
+
18
+ Manual requests arrive as Host `openchamber:session-metadata` writes. The
19
+ metadata route's committed-write callback explicitly notifies the title runtime;
20
+ it must not rely on OpenCode echoing Host metadata as `session.updated`.
21
+ `session-access.js` owns the production boundary: official client session reads
22
+ and title updates, a bounded `message.list({ order: 'desc' })` page projected into
23
+ chronological transcript records, and Host metadata reads/merge patches.
24
+ Only changed `titleRefresh` fields are persisted. Transient fields are removed
25
+ with RFC 7386 `null`, so `isGenerating` and `requestedAt` cannot survive a clear.
26
+ Unrelated goal, archive, and ownership metadata is never replaced by a title read.
27
+
28
+ After a successful title write or generation-state change, an authoritative full session row is published
29
+ as `session.updated` and applied to the session index. Connected clients receive
30
+ both loading metadata and the new title without reselecting the session. Neither
31
+ event represents an execution-status transition: the original session's busy /
32
+ retry status and subsequent execution events remain independently authoritative.
33
+ Publishing the loading row also covers first-send admission arriving before the
34
+ renderer inserts the new session: a metadata-only event would otherwise be ignored
35
+ for that still-unknown row.
36
+ This Host path serves web, Electron, and connected hosted/native mobile clients;
37
+ extension-only VS Code retains the limitation below.
38
+
16
39
  1. `createSessionTitleRuntime` is a consumer of the server's global SSE
17
40
  fan-out (`index.js` → `globalMessageStreamHub.subscribeEvent`). Purely event-driven — dormant sessions never
18
41
  generate anything.
19
- 2. Auto title refresh is intentionally sparse (title stability first). A newly
20
- observed root `session.created` generates its first title immediately on the
21
- first `session.status: idle`. A fork title (`(fork #n)`) waits for its first
42
+ 2. Auto title refresh is intentionally sparse (title stability first). A newly
43
+ observed root `session.created` generates its first title immediately on
44
+ the first native `session.inbox.enqueued` user item. Native `data` envelopes
45
+ are accepted alongside legacy `properties`. The admitted text is bounded
46
+ to the user-message limit and retained only until the generation starts;
47
+ it avoids a race with the not-yet-materialized message projection. Synthetic,
48
+ compaction, and move items cannot start this title. Busy/execution-start
49
+ events do not cancel this first-title timer: title generation and the main
50
+ assistant reply run concurrently. Generation publishes `isGenerating` via
51
+ the existing Host metadata path, then the final title through `session.updated`.
52
+ Later user admissions/idle events do not regenerate the initial title.
53
+ The legacy first-idle edge remains a fallback when no admission was observed.
54
+ A fork title (`(fork #n)`) waits for its first
22
55
  newly-created user message; the matching assistant completion triggers an
23
56
  immediate title refresh that bypasses inherited title metadata and throttle.
24
57
  If the fork's `session.created` was lost (SSE reconnect gap, server or
@@ -27,8 +60,8 @@ throttle (`TITLE_THROTTLE_MS`) when a refresh is armed.
27
60
  title + message created after the fork + activity timestamp not yet past
28
61
  the fork time), so the first-reply refresh still fires.
29
62
  Ordinary later idle transitions do **not** arm another refresh. Any
30
- `busy`/`retry` status or a fresh user `message.updated` still clears an
31
- already-armed timer (so initial/fork timers cancel if the user keeps going).
63
+ `busy`/`retry` status or a fresh user `message.updated` still clears an
64
+ ordinary already-armed timer; an admitted first-title timer is exempt.
32
65
  A sidebar smart-title request sets `titleRefresh.requestedAt`; its
33
66
  `session.updated` event arms the same flow immediately (forced / manual
34
67
  refresh is unaffected by the background gate).
@@ -0,0 +1,167 @@
1
+ import { afterEach, expect, it, vi } from 'vitest';
2
+ import { createSessionTitleRuntime } from './runtime.js';
3
+ import { mergeMetadataPatch } from '../session-metadata/session-metadata-store.js';
4
+ import { createTitleSessionAccess } from './session-access.js';
5
+ import { configureServerOpenCodeFetchGate } from '../opencode/server-opencode-fetch.js';
6
+
7
+ afterEach(() => {
8
+ vi.useRealTimers();
9
+ vi.unstubAllGlobals();
10
+ configureServerOpenCodeFetchGate(null);
11
+ });
12
+
13
+ it.each([false, true])('runs a Host smart-title request and clears loading (model failure: %s)', async (fail) => {
14
+ vi.useFakeTimers();
15
+ const metadata = { openchamber: { titleRefresh: { lastAutoTitle: 'Original', requestedAt: 1000 } } };
16
+ const session = { id: 'ses_title', title: 'Original', directory: '/repo', metadata };
17
+ const updates = [];
18
+ const sessionAccess = {
19
+ get: vi.fn(async () => structuredClone(session)),
20
+ update: vi.fn(async (_id, _directory, patch) => {
21
+ if (patch.title) session.title = patch.title;
22
+ if (patch.metadata) session.metadata = mergeMetadataPatch(session.metadata, patch.metadata);
23
+ updates.push(structuredClone(session));
24
+ }),
25
+ messages: vi.fn(async () => [
26
+ { info: { id: 'u1', role: 'user' }, parts: [{ type: 'text', text: 'Implement a title refresh' }] },
27
+ { info: { id: 'a1', role: 'assistant' }, parts: [{ type: 'text', text: 'Done' }] },
28
+ ]),
29
+ };
30
+ const generate = vi.fn(async () => {
31
+ expect(session.metadata.openchamber.titleRefresh.isGenerating).toBe(true);
32
+ if (fail) throw new Error('title model unavailable');
33
+ return { text: 'Title refresh', providerID: 'test', modelID: 'test' };
34
+ });
35
+ const runtime = createSessionTitleRuntime({
36
+ sessionAccess,
37
+ buildOpenCodeUrl: (path) => `http://opencode${path}`,
38
+ getOpenCodeAuthHeaders: () => ({}),
39
+ getSmallModelService: async () => ({ generateSmallModelText: generate }),
40
+ now: () => 1000,
41
+ });
42
+ try {
43
+ runtime.processPayload({ type: 'openchamber:session-metadata', properties: {
44
+ sessionID: session.id, directory: '/repo', metadata,
45
+ } });
46
+ await vi.runAllTimersAsync();
47
+ expect(generate).toHaveBeenCalledTimes(1);
48
+ expect(session.title).toBe(fail ? 'Original' : 'Title refresh');
49
+ if (fail) expect(session.metadata.openchamber.titleRefresh.lastError).toBe('title model unavailable');
50
+ expect(session.metadata.openchamber.titleRefresh.isGenerating).not.toBe(true);
51
+ expect(session.metadata.openchamber.titleRefresh.requestedAt).toBeUndefined();
52
+ expect(updates.some((row) => row.metadata.openchamber.titleRefresh.isGenerating)).toBe(true);
53
+ } finally {
54
+ runtime.stop();
55
+ }
56
+ });
57
+
58
+ it('uses the real v2 client and publishes the generated title without another session click', async () => {
59
+ vi.useFakeTimers();
60
+ configureServerOpenCodeFetchGate(null, { allowMissingContract: true });
61
+ let metadata = { openchamber: { titleRefresh: { lastAutoTitle: 'Original', requestedAt: 1000 } } };
62
+ const session = { id: 'ses_title', title: 'Original', location: { directory: '/repo' }, time: { created: 1, updated: 2 } };
63
+ const requests = [];
64
+ vi.stubGlobal('fetch', vi.fn(async (input, init) => {
65
+ const request = input instanceof Request ? input : new Request(input, init);
66
+ const url = new URL(request.url);
67
+ requests.push(`${request.method} ${url.pathname}`);
68
+ if (url.pathname.endsWith('/message')) {
69
+ expect(url.searchParams.get('order')).toBe('desc');
70
+ return Response.json({ data: [
71
+ { id: 'a1', type: 'assistant', content: [{ type: 'text', text: 'Done' }] },
72
+ { id: 'u1', type: 'user', text: 'Implement title refresh' },
73
+ ] });
74
+ }
75
+ if (request.method === 'PATCH') {
76
+ const body = await request.json();
77
+ expect(Object.keys(body)).toEqual(['title']);
78
+ session.title = body.title;
79
+ return new Response(null, { status: 204 });
80
+ }
81
+ return Response.json({ data: session });
82
+ }));
83
+ const events = [];
84
+ const access = createTitleSessionAccess({
85
+ buildOpenCodeUrl: (path) => `http://opencode${path}`,
86
+ getOpenCodeAuthHeaders: () => ({}),
87
+ readSessionMetadata: async () => metadata,
88
+ persistSessionMetadata: async (_id, patch) => {
89
+ metadata = mergeMetadataPatch(metadata, patch);
90
+ events.push({ type: 'metadata', metadata: structuredClone(metadata) });
91
+ },
92
+ publishSession: (row) => events.push({ type: 'session', row }),
93
+ });
94
+ const runtime = createSessionTitleRuntime({
95
+ sessionAccess: access,
96
+ getSmallModelService: async () => ({ generateSmallModelText: async () => ({ text: 'Title refresh', providerID: 'test', modelID: 'test' }) }),
97
+ now: () => 1000,
98
+ });
99
+ try {
100
+ runtime.processPayload({ type: 'openchamber:session-metadata', properties: {
101
+ sessionID: session.id, metadata, directory: '/repo',
102
+ } });
103
+ await vi.runAllTimersAsync();
104
+ expect(events.some((event) => event.metadata?.openchamber?.titleRefresh?.isGenerating === true)).toBe(true);
105
+ expect(events.at(-1)).toMatchObject({ type: 'session', row: { title: 'Title refresh', directory: '/repo' } });
106
+ expect(metadata.openchamber.titleRefresh.isGenerating).toBeUndefined();
107
+ expect(requests).toContain('PATCH /api/session/ses_title');
108
+ } finally {
109
+ runtime.stop();
110
+ }
111
+ });
112
+
113
+ it('starts the first title from admitted user text while the assistant is still running', async () => {
114
+ vi.useFakeTimers();
115
+ const session = {
116
+ id: 'ses_first', title: '', time: { created: 1, updated: 1 },
117
+ metadata: {},
118
+ };
119
+ let finishTitle;
120
+ const generation = new Promise((resolve) => { finishTitle = resolve; });
121
+ const generate = vi.fn(() => generation);
122
+ const access = {
123
+ get: vi.fn(async () => structuredClone(session)),
124
+ update: vi.fn(async (_id, _directory, patch) => {
125
+ if (patch.metadata) session.metadata = mergeMetadataPatch(session.metadata, patch.metadata);
126
+ if (patch.title) session.title = patch.title;
127
+ }),
128
+ messages: vi.fn(async () => []),
129
+ };
130
+ const runtime = createSessionTitleRuntime({
131
+ sessionAccess: access,
132
+ getSmallModelService: async () => ({ generateSmallModelText: generate }),
133
+ isTitleRefreshEnabled: () => true,
134
+ now: () => 1000,
135
+ });
136
+ const emit = (type, data) => runtime.processPayload({ type, data, created: 1000, location: { directory: '/repo' } });
137
+ try {
138
+ emit('session.created', { sessionID: session.id, location: { directory: '/repo' } });
139
+ emit('session.inbox.enqueued', { sessionID: session.id, inboxID: 'msg_synthetic', item: {
140
+ type: 'synthetic', delivery: 'steer', payload: { text: 'Internal instructions' },
141
+ } });
142
+ await vi.runAllTimersAsync();
143
+ expect(generate).not.toHaveBeenCalled();
144
+ emit('session.inbox.enqueued', { sessionID: session.id, inboxID: 'msg_first', item: {
145
+ type: 'user', delivery: 'steer', payload: { text: 'Implement a session title generator' },
146
+ } });
147
+ emit('session.execution.started', { sessionID: session.id });
148
+ emit('session.status', { sessionID: session.id, status: { type: 'busy' } });
149
+ await vi.runAllTimersAsync();
150
+ expect(generate).toHaveBeenCalledTimes(1);
151
+ expect(generate.mock.calls[0][0].prompt).toContain('Implement a session title generator');
152
+ expect(session.metadata.openchamber?.titleRefresh?.isGenerating).toBe(true);
153
+ expect(access.messages).not.toHaveBeenCalled();
154
+ finishTitle({ text: 'Session titles', providerID: 'test', modelID: 'test' });
155
+ await vi.runAllTimersAsync();
156
+ expect(session.title).toBe('Session titles');
157
+ expect(session.metadata.openchamber.titleRefresh.isGenerating).toBeUndefined();
158
+ emit('session.execution.succeeded', { sessionID: session.id });
159
+ emit('session.inbox.enqueued', { sessionID: session.id, inboxID: 'msg_second', item: {
160
+ type: 'user', delivery: 'steer', payload: { text: 'Follow up on the implementation' },
161
+ } });
162
+ await vi.runAllTimersAsync();
163
+ expect(generate).toHaveBeenCalledTimes(1);
164
+ } finally {
165
+ runtime.stop();
166
+ }
167
+ });
@@ -122,8 +122,8 @@ const buildTitleSystemPrompt = () => [
122
122
  'Name the main subject of the work.',
123
123
  'Keep that subject across follow-ups, polish, commit, push, tidy, and review.',
124
124
  'Switch only when the user clearly starts a different topic.',
125
- 'One line, ≤50 characters, no explanation.',
126
- 'Prefer ≤6 words or ≤6 CJK characters. No modifiers. No verbs.',
125
+ 'One line, no explanation.',
126
+ 'Hard limit: never exceed 6 CJK characters (or 6 words for English). No modifiers. No verbs.',
127
127
  'Use the language of the user messages.',
128
128
  'Keep technical terms, numbers, filenames, and HTTP codes exact.',
129
129
  'Never include tool names, summarizing, or generating.',
@@ -131,14 +131,18 @@ const buildTitleSystemPrompt = () => [
131
131
  ].join('\n');
132
132
 
133
133
  const extractSessionStatus = (payload) => {
134
- if (!payload || payload.type !== 'session.status') return null;
135
- const properties = payload.properties && typeof payload.properties === 'object' ? payload.properties : {};
134
+ if (!payload) return null;
135
+ const executionType = payload.type === 'session.execution.started' ? 'busy'
136
+ : payload.type === 'session.execution.succeeded' || payload.type === 'session.execution.failed' ? 'idle'
137
+ : null;
138
+ if (payload.type !== 'session.status' && !executionType) return null;
139
+ const properties = payload.properties ?? payload.data ?? {};
136
140
  const status = properties.status && typeof properties.status === 'object' ? properties.status : {};
137
141
  const info = properties.info && typeof properties.info === 'object' ? properties.info : {};
138
142
  const sessionId = typeof properties.sessionID === 'string' ? properties.sessionID.trim() : '';
139
- const type = typeof status.type === 'string'
143
+ const type = executionType ?? (typeof status.type === 'string'
140
144
  ? status.type.trim()
141
- : (typeof info.type === 'string' ? info.type.trim() : '');
145
+ : (typeof info.type === 'string' ? info.type.trim() : ''));
142
146
  if (!sessionId || !type) return null;
143
147
  const directory = typeof properties.directory === 'string' && properties.directory
144
148
  ? properties.directory
@@ -159,9 +163,11 @@ export const isSystemOwnedSession = (sessionOrInfo) => {
159
163
  };
160
164
 
161
165
  const extractTitleRefreshRequest = (payload) => {
162
- if (!payload || payload.type !== 'session.updated') return null;
166
+ if (!payload || (payload.type !== 'session.updated' && payload.type !== 'openchamber:session-metadata')) return null;
163
167
  const properties = payload.properties && typeof payload.properties === 'object' ? payload.properties : {};
164
- const info = properties.info && typeof properties.info === 'object' ? properties.info : {};
168
+ const info = payload.type === 'openchamber:session-metadata'
169
+ ? { id: properties.sessionID, metadata: properties.metadata }
170
+ : (properties.info && typeof properties.info === 'object' ? properties.info : {});
165
171
  if (isSystemOwnedSession(info)) return null;
166
172
  const sessionId = typeof info.id === 'string' ? info.id.trim() : '';
167
173
  const titleRefresh = info.metadata?.openchamber?.titleRefresh;
@@ -176,20 +182,35 @@ const extractTitleRefreshRequest = (payload) => {
176
182
 
177
183
  const extractCreatedSession = (payload) => {
178
184
  if (!payload || payload.type !== 'session.created') return null;
179
- const properties = payload.properties && typeof payload.properties === 'object' ? payload.properties : {};
180
- const info = properties.info && typeof properties.info === 'object' ? properties.info : {};
185
+ const properties = payload.properties ?? payload.data ?? {};
186
+ const info = properties.info ?? { ...properties, id: properties.sessionID };
181
187
  const sessionId = typeof info.id === 'string' ? info.id.trim() : '';
182
188
  if (!sessionId || (typeof info.parentID === 'string' && info.parentID)) return null;
183
189
  if (isSystemOwnedSession(info)) return null;
184
190
  const directory = typeof properties.directory === 'string' && properties.directory
185
191
  ? properties.directory
186
- : (typeof info.directory === 'string' ? info.directory : '');
187
- const createdAt = typeof info.time?.created === 'number' ? info.time.created : Date.now();
192
+ : (typeof info.location?.directory === 'string' ? info.location.directory : (typeof info.directory === 'string' ? info.directory : ''));
193
+ const createdAt = typeof info.time?.created === 'number' ? info.time.created : (payload.created ?? Date.now());
188
194
  const title = typeof info.title === 'string' ? info.title : '';
189
195
  return { sessionId, directory, createdAt, title };
190
196
  };
191
197
 
192
198
  const extractUserMessage = (payload) => {
199
+ if (payload?.type === 'session.inbox.enqueued') {
200
+ const properties = payload.properties ?? payload.data ?? {};
201
+ const item = properties.item;
202
+ if (typeof properties.sessionID !== 'string' || item?.type !== 'user') return null;
203
+ const text = typeof item.payload?.text === 'string' ? item.payload.text.slice(0, TRANSCRIPT_USER_CHAR_LIMIT) : '';
204
+ return {
205
+ sessionId: properties.sessionID,
206
+ createdAt: payload.created ?? 0,
207
+ directory: payload.location?.directory ?? '',
208
+ initialRecord: text.trim() ? {
209
+ info: { id: properties.inboxID, role: 'user' },
210
+ parts: [{ type: 'text', text }],
211
+ } : null,
212
+ };
213
+ }
193
214
  if (!payload || payload.type !== 'message.updated') return null;
194
215
  const info = payload.properties?.info;
195
216
  if (!info || typeof info !== 'object' || info.role !== 'user') return null;
@@ -351,6 +372,9 @@ export const buildLatestTitleTranscript = (
351
372
  export const createSessionTitleRuntime = ({
352
373
  buildOpenCodeUrl,
353
374
  getOpenCodeAuthHeaders,
375
+ /** Ticket 11: Host write admission shared with proxy (optional). */
376
+ serverOpenCodeFetch = null,
377
+ sessionAccess = null,
354
378
  getSmallModelService,
355
379
  quietMs = TITLE_QUIET_MS,
356
380
  throttleMs = TITLE_THROTTLE_MS,
@@ -362,6 +386,7 @@ export const createSessionTitleRuntime = ({
362
386
  const forcedRefreshes = new Set();
363
387
  const initialRefreshes = new Set();
364
388
  const newSessions = new Set();
389
+ const initialUserMessages = new Map();
365
390
  const forkFirstSendPending = new Map();
366
391
  const forkFirstRefreshes = new Set();
367
392
  /** In-memory throttle timestamps (metadata also persists across restarts). */
@@ -377,6 +402,20 @@ export const createSessionTitleRuntime = ({
377
402
  };
378
403
 
379
404
  const openCodeFetch = async (pathname, { directory, method = 'GET', body } = {}) => {
405
+ if (sessionAccess) {
406
+ const sessionId = decodeURIComponent(pathname.slice('/session/'.length));
407
+ return method === 'PATCH'
408
+ ? sessionAccess.update(sessionId, directory, body)
409
+ : sessionAccess.get(sessionId, directory);
410
+ }
411
+ if (typeof serverOpenCodeFetch === 'function') {
412
+ return serverOpenCodeFetch(pathname, {
413
+ directory,
414
+ method,
415
+ body,
416
+ timeoutMs: FETCH_TIMEOUT_MS,
417
+ });
418
+ }
380
419
  const base = buildOpenCodeUrl(pathname, '');
381
420
  const url = directory ? `${base}?directory=${encodeURIComponent(directory)}` : base;
382
421
  const response = await fetch(url, {
@@ -396,6 +435,7 @@ export const createSessionTitleRuntime = ({
396
435
  };
397
436
 
398
437
  const fetchRecentMessages = async (sessionId, directory) => {
438
+ if (sessionAccess) return sessionAccess.messages(sessionId, directory, TRANSCRIPT_MESSAGE_LIMIT);
399
439
  const base = buildOpenCodeUrl(`/session/${encodeURIComponent(sessionId)}/message`, '');
400
440
  const params = new URLSearchParams({ limit: String(TRANSCRIPT_MESSAGE_LIMIT) });
401
441
  if (directory) params.set('directory', directory);
@@ -421,20 +461,13 @@ export const createSessionTitleRuntime = ({
421
461
 
422
462
  const meta = readTitleRefreshMeta(session);
423
463
  if (meta.titleRefresh.isGenerating !== true) return;
424
- const {
425
- isGenerating: _isGenerating,
426
- requestedAt: _requestedAt,
427
- ...titleRefresh
428
- } = meta.titleRefresh;
429
464
  await openCodeFetch(`/session/${encodeURIComponent(sessionId)}`, {
430
465
  directory,
431
466
  method: 'PATCH',
432
467
  body: {
433
468
  metadata: {
434
- ...meta.metadata,
435
469
  openchamber: {
436
- ...meta.openchamber,
437
- titleRefresh,
470
+ titleRefresh: { isGenerating: null, requestedAt: null },
438
471
  },
439
472
  },
440
473
  },
@@ -446,17 +479,13 @@ export const createSessionTitleRuntime = ({
446
479
  .catch(() => null);
447
480
  if (!session || typeof session !== 'object') return;
448
481
 
449
- const meta = readTitleRefreshMeta(session);
450
482
  await openCodeFetch(`/session/${encodeURIComponent(sessionId)}`, {
451
483
  directory,
452
484
  method: 'PATCH',
453
485
  body: {
454
486
  metadata: {
455
- ...meta.metadata,
456
487
  openchamber: {
457
- ...meta.openchamber,
458
488
  titleRefresh: {
459
- ...meta.titleRefresh,
460
489
  lastError: error instanceof Error ? error.message : String(error),
461
490
  failedAt: now(),
462
491
  },
@@ -505,11 +534,8 @@ export const createSessionTitleRuntime = ({
505
534
  method: 'PATCH',
506
535
  body: {
507
536
  metadata: {
508
- ...meta.metadata,
509
537
  openchamber: {
510
- ...meta.openchamber,
511
538
  titleRefresh: {
512
- ...meta.titleRefresh,
513
539
  activityUpdatedAt,
514
540
  },
515
541
  },
@@ -524,6 +550,8 @@ export const createSessionTitleRuntime = ({
524
550
  const generateTitle = async (sessionId, directory) => {
525
551
  const forceRefresh = forcedRefreshes.delete(sessionId);
526
552
  const initialRefresh = initialRefreshes.delete(sessionId);
553
+ const initialUserMessage = initialUserMessages.get(sessionId);
554
+ initialUserMessages.delete(sessionId);
527
555
  const forkFirstRefresh = forkFirstRefreshes.delete(sessionId);
528
556
  if (!forceRefresh && !isTitleRefreshEnabled()) return;
529
557
 
@@ -558,15 +586,14 @@ export const createSessionTitleRuntime = ({
558
586
  return;
559
587
  }
560
588
 
561
- const messages = await fetchRecentMessages(sessionId, directory);
589
+ const messages = initialUserMessage ? [initialUserMessage] : await fetchRecentMessages(sessionId, directory);
562
590
  if (!messages || messages.length === 0) return;
563
591
 
564
592
  const { transcript, lastAssistantId, realUserCount, languageSample } = buildLatestTitleTranscript(messages);
565
593
  if (!transcript) return;
566
594
 
567
- // Let OpenCode's first-message title land first. We only refresh once the
568
- // conversation has moved on (2+ real user turns), unless the title is
569
- // still the default placeholder.
595
+ // Initial titles use the admitted first user message immediately; ordinary
596
+ // later refreshes still require a moved-on conversation or placeholder.
570
597
  if (!forceRefresh && !initialRefresh && !forkFirstRefresh && realUserCount < 2 && !isDefaultSessionTitle(currentTitle)) {
571
598
  return;
572
599
  }
@@ -597,14 +624,11 @@ export const createSessionTitleRuntime = ({
597
624
  method: 'PATCH',
598
625
  body: {
599
626
  metadata: {
600
- ...meta.metadata,
601
627
  openchamber: {
602
- ...meta.openchamber,
603
628
  titleRefresh: {
604
- ...meta.titleRefresh,
605
629
  isGenerating: true,
606
- lastError: undefined,
607
- failedAt: undefined,
630
+ lastError: null,
631
+ failedAt: null,
608
632
  },
609
633
  },
610
634
  },
@@ -677,9 +701,6 @@ export const createSessionTitleRuntime = ({
677
701
  const generatedAt = now();
678
702
  lastGeneratedAtBySession.set(sessionId, generatedAt);
679
703
 
680
- const currentMetadata = freshMeta.metadata;
681
- const currentNamespace = freshMeta.openchamber;
682
-
683
704
  console.log(`[session-title] refreshed ${sessionId} → "${nextTitle}" via ${generated.providerID}/${generated.modelID}`);
684
705
  await openCodeFetch(`/session/${encodeURIComponent(sessionId)}`, {
685
706
  directory,
@@ -687,11 +708,8 @@ export const createSessionTitleRuntime = ({
687
708
  body: {
688
709
  title: nextTitle,
689
710
  metadata: {
690
- ...currentMetadata,
691
711
  openchamber: {
692
- ...currentNamespace,
693
712
  titleRefresh: {
694
- ...freshMeta.titleRefresh,
695
713
  lastAutoTitle: nextTitle,
696
714
  forMessageID: lastAssistantId || latestAssistantId || '',
697
715
  generatedAt,
@@ -723,6 +741,7 @@ export const createSessionTitleRuntime = ({
723
741
 
724
742
  const processPayload = (payload, directoryHint = '') => {
725
743
  if (stopped) return;
744
+ directoryHint = directoryHint || payload?.location?.directory || '';
726
745
  const createdSession = extractCreatedSession(payload);
727
746
  if (createdSession) {
728
747
  if (isForkedSessionTitle(createdSession.title)) {
@@ -758,13 +777,18 @@ export const createSessionTitleRuntime = ({
758
777
  initialRefreshes.add(status.sessionId);
759
778
  armTimer(status.sessionId, status.directory || directoryHint, 0);
760
779
  }
761
- } else {
780
+ } else if (!initialRefreshes.has(status.sessionId)) {
762
781
  clearTimer(status.sessionId);
763
782
  }
764
783
  return;
765
784
  }
766
785
  const userMessage = extractUserMessage(payload);
767
786
  if (userMessage) {
787
+ if (userMessage.initialRecord && newSessions.delete(userMessage.sessionId)) {
788
+ initialRefreshes.add(userMessage.sessionId);
789
+ initialUserMessages.set(userMessage.sessionId, userMessage.initialRecord);
790
+ armTimer(userMessage.sessionId, userMessage.directory || directoryHint, 0);
791
+ }
768
792
  const pendingFork = forkFirstSendPending.get(userMessage.sessionId);
769
793
  if (pendingFork && userMessage.createdAt > pendingFork.createdAt) {
770
794
  pendingFork.hasNewUserMessage = true;
@@ -780,7 +804,7 @@ export const createSessionTitleRuntime = ({
780
804
  // session settles. Only a message created after the timer was armed
781
805
  // means the user actually moved on.
782
806
  const armed = timers.get(userMessage.sessionId);
783
- if (armed && userMessage.createdAt >= armed.armedAt) {
807
+ if (armed && !initialRefreshes.has(userMessage.sessionId) && userMessage.createdAt >= armed.armedAt) {
784
808
  clearTimer(userMessage.sessionId);
785
809
  }
786
810
  }
@@ -792,6 +816,7 @@ export const createSessionTitleRuntime = ({
792
816
  clearTimeout(timer);
793
817
  }
794
818
  timers.clear();
819
+ initialUserMessages.clear();
795
820
  };
796
821
 
797
822
  return { processPayload, stop, armTimer };
@@ -0,0 +1,46 @@
1
+ import { makeOpenCodeV2Client } from '../opencode/v2-client.js';
2
+ import { projectSessionWithHostMetadata } from '../session-metadata/session-projection.js';
3
+ import { projectSessionMessageRecords } from '../session-turn-pages/session-message-projection.js';
4
+
5
+ /** Title reads use OpenCode; title-refresh metadata and its notifications belong to the Host. */
6
+ export function createTitleSessionAccess({
7
+ buildOpenCodeUrl,
8
+ getOpenCodeAuthHeaders,
9
+ readSessionMetadata,
10
+ persistSessionMetadata,
11
+ publishSession,
12
+ }) {
13
+ const client = () => makeOpenCodeV2Client({
14
+ baseUrl: buildOpenCodeUrl('/', '').replace(/\/$/, ''),
15
+ authHeaders: getOpenCodeAuthHeaders(),
16
+ });
17
+ const options = () => ({ signal: AbortSignal.timeout(30_000) });
18
+ const get = async (sessionID) => {
19
+ const session = await client().session.get({ sessionID }, options());
20
+ return projectSessionWithHostMetadata(session, await readSessionMetadata(sessionID));
21
+ };
22
+ return {
23
+ get,
24
+ async messages(sessionID, _directory, limit) {
25
+ const page = await client().message.list({ sessionID, limit, order: 'desc' }, options());
26
+ if (!Array.isArray(page?.data)) throw new Error('Session title: invalid message page');
27
+ return projectSessionMessageRecords([...page.data].reverse());
28
+ },
29
+ async update(sessionID, directory, patch) {
30
+ if (typeof patch.title === 'string') {
31
+ await client().session.update({ sessionID, title: patch.title }, options());
32
+ }
33
+ // Never write the complete metadata snapshot back: goal/archive/ownership
34
+ // may have changed while the title model was running.
35
+ const refresh = patch.metadata?.openchamber?.titleRefresh;
36
+ if (refresh) {
37
+ await persistSessionMetadata(sessionID, { openchamber: { titleRefresh: refresh } });
38
+ }
39
+ if (typeof patch.title === 'string' || (refresh && Object.hasOwn(refresh, 'isGenerating'))) {
40
+ const session = await get(sessionID);
41
+ await publishSession(session, directory);
42
+ return session;
43
+ }
44
+ },
45
+ };
46
+ }