@openchambery/web 2.0.0-beta.9 → 2.0.0

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 (237) hide show
  1. package/README.md +14 -0
  2. package/dist/assets/{AgentAvatar-CzTtVMnI.js → AgentAvatar-C1MGZ7qC.js} +1 -1
  3. package/dist/assets/AssistantView-vqCFvVo0.js +3 -0
  4. package/dist/assets/{DiagramView-oeXj4YHn.js → DiagramView-CYO5iVVm.js} +1 -1
  5. package/dist/assets/{FadeInOnReveal-DCqrQkkz.js → FadeInOnReveal-BMSk_tY-.js} +1 -1
  6. package/dist/assets/Icon-BdzPRCSy.js +1 -0
  7. package/dist/assets/MarkdownRenderer-FMynjkGR.js +2 -0
  8. package/dist/assets/{MarkdownRendererImpl-CahUctxC.js → MarkdownRendererImpl-DoWvw5jj.js} +3 -3
  9. package/dist/assets/{MarkstreamRendererImpl-5JUaTSdl.js → MarkstreamRendererImpl-DoeYajmw.js} +1 -1
  10. package/dist/assets/{MobileDetailNavigation-agQ-Yhpd.js → MobileDetailNavigation-BzBz-co_.js} +1 -1
  11. package/dist/assets/{MobileShareBridge-yCN95dYg.js → MobileShareBridge-D1Uoj6l_.js} +3 -3
  12. package/dist/assets/MobileSurface-8YpZ51Qo.js +2 -0
  13. package/dist/assets/{MultiRunWindow-C0HqMFpp.js → MultiRunWindow-BxcYUPta.js} +1 -1
  14. package/dist/assets/{OnboardingScreen-O4cGdz5p.js → OnboardingScreen-yRdQTR1M.js} +1 -1
  15. package/dist/assets/{OverlayScrollbar-CRfGtcmG.js → OverlayScrollbar-BUjtqnxn.js} +1 -1
  16. package/dist/assets/SettingsView-D7KrvqvH.js +25 -0
  17. package/dist/assets/{SettingsWindow-B_d5Aqq3.js → SettingsWindow-DX2w-cDc.js} +1 -1
  18. package/dist/assets/{TerminalView-DdNQQb8W.js → TerminalView-CiUcCQPm.js} +1 -1
  19. package/dist/assets/{ToolOutputDialog-4DC5OE1J.js → ToolOutputDialog-DQvXtK71.js} +1 -1
  20. package/dist/assets/appThemeRegistry-BXC6cIDE.js +1 -0
  21. package/dist/assets/{assistant-attachment-cache-DphJleHr.js → assistant-attachment-cache-sG8YQsVs.js} +1 -1
  22. package/dist/assets/{button-DIIrsMjW.js → button-CUwjpv-i.js} +1 -1
  23. package/dist/assets/{collapsible-CnvUeYst.js → collapsible-paItOH3A.js} +1 -1
  24. package/dist/assets/{context-menu-BGBUpZEQ.js → context-menu-K6zNumqN.js} +1 -1
  25. package/dist/assets/{desktopHosts-JGFqhlaM.js → desktopHosts-DuftEj8B.js} +1 -1
  26. package/dist/assets/desktopRelayRestore-g378BdZf.js +1 -0
  27. package/dist/assets/{diagram-rvn7_qBM.js → diagram-D6nY5hbD.js} +1 -1
  28. package/dist/assets/dialog-PjKWayIU.js +1 -0
  29. package/dist/assets/dropdown-menu.styles-ayLWk2gf.js +1 -0
  30. package/dist/assets/{es-Dsmc0A_3.js → es-BBbon7SL.js} +4 -4
  31. package/dist/assets/fileMentionTouchSelection-78v5mNTE.js +2 -0
  32. package/dist/assets/{fileQueries-DN6gbLhM.js → fileQueries-DuF89nFO.js} +1 -1
  33. package/dist/assets/{fr-C_ZlZDI2.js → fr-BsKj2CTi.js} +4 -4
  34. package/dist/assets/{gitApi-da8etTTe.js → gitApi-eoKSJcvW.js} +4 -4
  35. package/dist/assets/{imageSource-BMztRu0c.js → imageSource-BpUsiwFd.js} +1 -1
  36. package/dist/assets/{input-BVu-iykl.js → input-BeVw4D0W.js} +1 -1
  37. package/dist/assets/insertionBoundaries-BI3RHOpf.js +1 -0
  38. package/dist/assets/{ja-BqqTaWTi.js → ja-1mgxunoM.js} +4 -4
  39. package/dist/assets/{ko-DP2XPM37.js → ko-Ra9znPcL.js} +4 -4
  40. package/dist/assets/main-CQpUPjPK.js +169 -0
  41. package/dist/assets/{main-KwlAomA8.js → main-MN0YKJ0_.js} +2 -2
  42. package/dist/assets/main-qSeeJRW1.css +1 -0
  43. package/dist/assets/{markdownHeightCache-D9-B1wdY.js → markdownHeightCache-CGCWmHgq.js} +1 -1
  44. package/dist/assets/{mcpQueries-3GTWeMU-.js → mcpQueries-X424Iq2z.js} +1 -1
  45. package/dist/assets/miniChat-DFIl76ku.js +2 -0
  46. package/dist/assets/mobile-C3fWXGQJ.js +2 -0
  47. package/dist/assets/multirun-DuSX3YRs.js +3 -0
  48. package/dist/assets/persistence-Dnj1QOdX.js +3 -0
  49. package/dist/assets/pl-B714sdql.js +15 -0
  50. package/dist/assets/platform-jCZ3KfMQ.js +6 -0
  51. package/dist/assets/projectMeta-Cn08pTNi.js +1 -0
  52. package/dist/assets/{pt-BR-W09Q2pvn.js → pt-BR-DVoT_I7_.js} +4 -4
  53. package/dist/assets/{relay-image-stream-DrdVj99c.js → relay-image-stream-D2XooXKZ.js} +1 -1
  54. package/dist/assets/renderElectronMiniChatApp-CRPdctIn.js +2 -0
  55. package/dist/assets/renderMobileApp-CVURQbeh.js +28 -0
  56. package/dist/assets/{runtime-fetch-Fs9ZV2V8.js → runtime-fetch-CiqQd__Y.js} +1 -1
  57. package/dist/assets/runtime-switch-BJM2k_uH.js +1 -0
  58. package/dist/assets/runtimeConfig-xivE1wxC.js +2 -0
  59. package/dist/assets/runtimeEndpointReset-B8DzKzwg.js +148 -0
  60. package/dist/assets/runtimeEndpointReset-D_vfNOQz.css +1 -0
  61. package/dist/assets/sessionChangeSummary-yBsR2pyd.js +2 -0
  62. package/dist/assets/{sessionLookup-0Z4e-J1g.js → sessionLookup-1Z1FWZRB.js} +1 -1
  63. package/dist/assets/{sortable-tabs-strip-CE7W7lJ9.js → sortable-tabs-strip-D611LPyd.js} +1 -1
  64. package/dist/assets/src-TBM7scxy.css +2 -0
  65. package/dist/assets/{store-jaP79mqI.js → store-l_X_jQNz.js} +4 -4
  66. package/dist/assets/{terminalApi-mDR2jXTo.js → terminalApi-BZW_rAc3.js} +1 -1
  67. package/dist/assets/{textarea-XD2oX_Ee.js → textarea-QtZjf4L8.js} +1 -1
  68. package/dist/assets/{tooltip-DYeQ_Hvy.js → tooltip-DDNnmcXI.js} +1 -1
  69. package/dist/assets/{uk-BgFzz8m2.js → uk-ChNnTgKP.js} +4 -4
  70. package/dist/assets/{url-CGbJbiJq.js → url-DNRMq_0R.js} +1 -1
  71. package/dist/assets/{useAppFontEffects-y_GTVXhJ.js → useAppFontEffects-DJy8P5By.js} +55 -55
  72. package/dist/assets/{useAssistantUIStore-Bz4tRflF.js → useAssistantUIStore-DBwfg9nX.js} +1 -1
  73. package/dist/assets/useDesktopWindowControlsLayout-DlwSomVK.js +1 -0
  74. package/dist/assets/{useEffectiveDirectory-BW9jNyuK.js → useEffectiveDirectory-eJorVTT9.js} +1 -1
  75. package/dist/assets/{useFontPreferences-Bjx8zkGM.js → useFontPreferences-Bjgt_lzl.js} +1 -1
  76. package/dist/assets/useMobileNavigationStore-A_ZNTfpl.js +415 -0
  77. package/dist/assets/useSessionAutoCleanup-CqC3dEou.js +2 -0
  78. package/dist/assets/{useUIStore-syVmbuDA.js → useUIStore-Dfx6TKvi.js} +1 -1
  79. package/dist/assets/{useWorkerHighlightedLines-BUcSLKlL.js → useWorkerHighlightedLines-B0vSL6jr.js} +1 -1
  80. package/dist/assets/vendor-opencode-client-MF2GBrFx.js +12 -0
  81. package/dist/assets/{vendor-zustand-B4dlGEM2.js → vendor-zustand-DzRqcymP.js} +1 -1
  82. package/dist/assets/{zh-CN-BhqSLgUY.js → zh-CN-DHEFf4u1.js} +4 -4
  83. package/dist/assets/{zh-TW-BsaBgY-p.js → zh-TW-rdSa8atW.js} +4 -4
  84. package/dist/index.html +13 -13
  85. package/dist/mini-chat.html +13 -13
  86. package/dist/mobile.html +13 -13
  87. package/package.json +3 -3
  88. package/server/index.js +8 -20
  89. package/server/lib/client-auth/remote-clients.js +15 -0
  90. package/server/lib/client-auth/remote-clients.test.js +42 -1
  91. package/server/lib/git/DOCUMENTATION.md +1 -0
  92. package/server/lib/git/routes.js +7 -4
  93. package/server/lib/git/routes.test.js +34 -0
  94. package/server/lib/git/service.js +3 -1
  95. package/server/lib/llm/DOCUMENTATION.md +6 -4
  96. package/server/lib/llm/generate.js +8 -9
  97. package/server/lib/llm/generate.test.js +33 -6
  98. package/server/lib/message-queue/service.js +1 -1
  99. package/server/lib/opencode/DOCUMENTATION.md +33 -22
  100. package/server/lib/opencode/agents.js +13 -22
  101. package/server/lib/opencode/auth-state-runtime.js +3 -3
  102. package/server/lib/opencode/auth-state-runtime.test.js +59 -0
  103. package/server/lib/opencode/auth.d.ts +13 -0
  104. package/server/lib/opencode/auth.js +74 -64
  105. package/server/lib/opencode/auth.test.js +57 -0
  106. package/server/lib/opencode/commands.js +1 -1
  107. package/server/lib/opencode/config-entities-layering.test.js +72 -0
  108. package/server/lib/opencode/config-entity-routes.js +10 -123
  109. package/server/lib/opencode/config-entity-routes.test.js +23 -11
  110. package/server/lib/opencode/config-files.d.ts +17 -0
  111. package/server/lib/opencode/config-files.js +187 -0
  112. package/server/lib/opencode/config-files.test.js +201 -0
  113. package/server/lib/opencode/core-routes.js +17 -1
  114. package/server/lib/opencode/core-routes.test.js +43 -15
  115. package/server/lib/opencode/credential-boundary.d.ts +1 -0
  116. package/server/lib/opencode/credential-boundary.js +11 -0
  117. package/server/lib/opencode/ensure-cli.js +4 -1
  118. package/server/lib/opencode/env-config.js +4 -1
  119. package/server/lib/opencode/env-config.test.js +28 -0
  120. package/server/lib/opencode/env-runtime.test.js +18 -1
  121. package/server/lib/opencode/feature-routes-runtime.js +9 -7
  122. package/server/lib/opencode/hmr-state-runtime.js +4 -4
  123. package/server/lib/opencode/lifecycle.js +1 -49
  124. package/server/lib/opencode/lifecycle.test.js +30 -4
  125. package/server/lib/opencode/location-release.d.ts +8 -0
  126. package/server/lib/opencode/location-release.js +32 -0
  127. package/server/lib/opencode/location-release.test.js +43 -0
  128. package/server/lib/opencode/mcp.js +1 -1
  129. package/server/lib/opencode/opencode2-pin.js +2 -1
  130. package/server/lib/opencode/owned-runtime-upgrade.test.js +5 -5
  131. package/server/lib/opencode/plugin-routes.test.js +4 -2
  132. package/server/lib/opencode/plugins.js +12 -60
  133. package/server/lib/opencode/plugins.test.js +5 -3
  134. package/server/lib/opencode/providers.js +6 -3
  135. package/server/lib/opencode/proxy.js +7 -0
  136. package/server/lib/opencode/proxy.test.js +13 -0
  137. package/server/lib/opencode/remote-upgrade.integration.test.js +66 -0
  138. package/server/lib/opencode/routes.js +10 -8
  139. package/server/lib/opencode/routes.test.js +51 -10
  140. package/server/lib/opencode/runtime-contract.js +3 -2
  141. package/server/lib/opencode/runtime-contract.test.js +12 -12
  142. package/server/lib/opencode/settings-normalization-runtime.js +3 -1
  143. package/server/lib/opencode/settings-normalization-runtime.test.js +36 -0
  144. package/server/lib/opencode/settings-runtime.test.js +58 -1
  145. package/server/lib/opencode/shared.js +6 -175
  146. package/server/lib/opencode/update-discovery.js +39 -0
  147. package/server/lib/opencode/update-discovery.test.js +39 -0
  148. package/server/lib/opencode/upgrade-screen.js +4 -11
  149. package/server/lib/opencode/upgrade-screen.test.js +25 -25
  150. package/server/lib/opencode/version-alignment.test.js +40 -0
  151. package/server/lib/question-auto-delegate/DOCUMENTATION.md +21 -6
  152. package/server/lib/question-auto-delegate/core.d.ts +5 -1
  153. package/server/lib/question-auto-delegate/core.js +21 -13
  154. package/server/lib/question-auto-delegate/form-io.d.ts +6 -0
  155. package/server/lib/question-auto-delegate/form-io.js +39 -0
  156. package/server/lib/question-auto-delegate/runtime.js +4 -24
  157. package/server/lib/question-auto-delegate/runtime.test.js +138 -0
  158. package/server/lib/quota/DOCUMENTATION.md +9 -0
  159. package/server/lib/quota/provider-credentials.test.js +29 -0
  160. package/server/lib/quota/providers/claude.js +4 -4
  161. package/server/lib/quota/providers/codex.js +4 -4
  162. package/server/lib/quota/providers/codex.test.js +1 -1
  163. package/server/lib/quota/providers/copilot.js +5 -5
  164. package/server/lib/quota/providers/google/auth.js +3 -3
  165. package/server/lib/quota/providers/google/index.js +2 -2
  166. package/server/lib/quota/providers/index.js +3 -7
  167. package/server/lib/quota/providers/index.test.js +2 -2
  168. package/server/lib/quota/providers/kimi.js +4 -4
  169. package/server/lib/quota/providers/minimax-shared.js +4 -4
  170. package/server/lib/quota/providers/nanogpt.js +4 -4
  171. package/server/lib/quota/providers/openai.js +4 -4
  172. package/server/lib/quota/providers/openrouter.js +4 -4
  173. package/server/lib/quota/providers/wafer.js +4 -4
  174. package/server/lib/quota/providers/zai.js +4 -4
  175. package/server/lib/quota/providers/zhipuai-coding-plan.js +6 -20
  176. package/server/lib/quota/routes.js +1 -1
  177. package/server/lib/relay/DOCUMENTATION.md +3 -1
  178. package/server/lib/relay/service.js +1 -0
  179. package/server/lib/relay/service.test.js +13 -0
  180. package/server/lib/scheduled-tasks/DOCUMENTATION.md +8 -1
  181. package/server/lib/scheduled-tasks/runtime.js +12 -4
  182. package/server/lib/scheduled-tasks/runtime.test.js +81 -6
  183. package/server/lib/session-goal/DOCUMENTATION.md +11 -0
  184. package/server/lib/session-goal/runtime.js +4 -4
  185. package/server/lib/session-goal/runtime.test.js +32 -3
  186. package/server/lib/session-index/DOCUMENTATION.md +7 -0
  187. package/server/lib/session-index/event-ingest.js +13 -0
  188. package/server/lib/session-index/event-ingest.test.js +7 -0
  189. package/server/lib/session-index/service.js +45 -0
  190. package/server/lib/session-index/service.test.js +47 -0
  191. package/server/lib/session-metadata/DOCUMENTATION.md +6 -0
  192. package/server/lib/session-metadata/session-metadata-store.test.js +23 -0
  193. package/server/lib/session-metadata/session-projection.d.ts +1 -0
  194. package/server/lib/session-metadata/session-projection.js +23 -0
  195. package/server/lib/session-metadata/session-projection.test.js +35 -0
  196. package/server/lib/session-title/DOCUMENTATION.md +52 -14
  197. package/server/lib/session-title/native-runtime.test.js +59 -5
  198. package/server/lib/session-title/runtime.js +63 -17
  199. package/server/lib/session-title/session-access.js +108 -3
  200. package/server/lib/session-title/session-access.test.js +125 -7
  201. package/server/lib/tts/DOCUMENTATION.md +7 -5
  202. package/server/lib/tts/credential-source.test.js +23 -0
  203. package/server/lib/tts/routes.js +2 -2
  204. package/server/lib/tts/service.js +16 -37
  205. package/dist/assets/AssistantView-CLFPj5Ki.js +0 -3
  206. package/dist/assets/Icon-B8fSTLdP.js +0 -1
  207. package/dist/assets/MarkdownRenderer-BK69MEHQ.js +0 -2
  208. package/dist/assets/MobileSurface-D0HP_lTS.js +0 -2
  209. package/dist/assets/SettingsView-xLYb22wV.js +0 -25
  210. package/dist/assets/appThemeRegistry-LkVH62uF.js +0 -1
  211. package/dist/assets/desktopRelayRestore-CHEwrI93.js +0 -1
  212. package/dist/assets/dialog-CQ63sc9E.js +0 -1
  213. package/dist/assets/dropdown-menu.styles-Bzo6L3FH.js +0 -1
  214. package/dist/assets/fileMentionTouchSelection-CBzDuQxP.js +0 -3
  215. package/dist/assets/insertionBoundaries-DQrE5qG2.js +0 -1
  216. package/dist/assets/main-D2BzpnUt.css +0 -1
  217. package/dist/assets/main-DYISNvWY.js +0 -169
  218. package/dist/assets/miniChat-DYIqdWi7.js +0 -2
  219. package/dist/assets/mobile-CwiCcmWt.js +0 -2
  220. package/dist/assets/multirun-C3snjTFs.js +0 -4
  221. package/dist/assets/persistence-D0y7226-.js +0 -3
  222. package/dist/assets/pl-BCf5uLlK.js +0 -15
  223. package/dist/assets/platform-CurqO28D.js +0 -6
  224. package/dist/assets/projectMeta-BkhYFMaM.js +0 -3
  225. package/dist/assets/renderElectronMiniChatApp-7iyh_419.js +0 -2
  226. package/dist/assets/renderMobileApp-y0_NeckR.js +0 -28
  227. package/dist/assets/runtime-switch-LvWDU-SL.js +0 -1
  228. package/dist/assets/runtimeConfig-12C5j02f.js +0 -2
  229. package/dist/assets/runtimeEndpointReset-DTY2ZXXb.js +0 -149
  230. package/dist/assets/sessionChangeSummary-CwPBIb3Z.js +0 -2
  231. package/dist/assets/src-lS1keDVV.css +0 -2
  232. package/dist/assets/useDesktopWindowControlsLayout-CR_DcY77.js +0 -1
  233. package/dist/assets/useMobileNavigationStore-DwupvLSA.js +0 -414
  234. package/dist/assets/useSessionAutoCleanup-DkGO_usj.js +0 -2
  235. package/dist/assets/vendor-opencode-client-C12gfjag.js +0 -12
  236. package/server/lib/opencode/snippets.js +0 -242
  237. package/server/lib/opencode/snippets.test.js +0 -97
@@ -4,8 +4,8 @@ Server-side watcher that regenerates a session's sidebar title from the
4
4
  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
- OpenCode only auto-titles once from the first user message
8
- (`SessionPrompt.ensureTitle`). This module auto-refreshes sparsely (first user
7
+ OpenCode starts its own initial title through `SessionTitle.generate`.
8
+ This module auto-refreshes sparsely (first user
9
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
@@ -50,18 +50,21 @@ extension-only VS Code retains the limitation below.
50
50
  assistant reply run concurrently. Generation publishes `isGenerating` via
51
51
  the existing Host metadata path, then the final title through `session.updated`.
52
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
55
- newly-created user message; the matching assistant completion triggers an
56
- immediate title refresh that bypasses inherited title metadata and throttle.
53
+ If `session.created` was missed, the admission's session read recovers
54
+ an empty/default title with no previous auto-title metadata and starts
55
+ immediately. The legacy first-idle edge remains a fallback when no
56
+ admission was observed.
57
+ A fork title (`(fork #n)`) refreshes on its first newly-admitted user
58
+ message, bypassing inherited title metadata and throttle without waiting
59
+ for the assistant. Legacy message-only events retain the idle fallback.
57
60
  If the fork's `session.created` was lost (SSE reconnect gap, server or
58
61
  OpenCode restart), the first newly-created user message lazily re-registers
59
62
  the pending fork from the session read inside `recordUserActivity` (fork
60
63
  title + message created after the fork + activity timestamp not yet past
61
- the fork time), so the first-reply refresh still fires.
64
+ the fork time), so the first-send refresh still fires.
62
65
  Ordinary later idle transitions do **not** arm another refresh. Any
63
66
  `busy`/`retry` status or a fresh user `message.updated` still clears an
64
- ordinary already-armed timer; an admitted first-title timer is exempt.
67
+ ordinary already-armed timer; admitted initial/fork-title timers are exempt.
65
68
  A sidebar smart-title request sets `titleRefresh.requestedAt`; its
66
69
  `session.updated` event arms the same flow immediately (forced / manual
67
70
  refresh is unaffected by the background gate).
@@ -124,6 +127,8 @@ and other session updates keep the existing ordering timestamp unchanged.
124
127
  `sessionTitleRefreshEnabled` in OpenChamber settings (Settings → Chat,
125
128
  default on) controls background title refreshes. Explicit smart-title actions
126
129
  still run when background refresh is disabled.
130
+ The gate controls model generation, not synchronization of an already committed
131
+ title authority; reconciliation continues without additional model calls.
127
132
 
128
133
  Settings → Summary AI controls the title model and the optional
129
134
  `summarySessionTitlePrompt` override. The configured custom API receives the
@@ -131,18 +136,51 @@ title transcript only after the user explicitly selects that source.
131
136
 
132
137
  ## Manual rename contract
133
138
 
134
- After this module writes a title, further auto-refreshes only proceed while
135
- `session.title === titleRefresh.lastAutoTitle` (or the title is still the
136
- OpenCode default). Renaming in the sidebar breaks that equality and stops
137
- auto updates for that session.
139
+ OpenChamber owns the title once it generates one or the user renames it here.
140
+ `metadata.openchamber.titleAuthority` stores `{ sessionID, title, revision,
141
+ source }` on the OpenCode session via the metadata store. `sessionID` prevents
142
+ forks inheriting a parent's title lock. Existing `lastAutoTitle` records are not
143
+ promoted: they cannot distinguish an external manual rename from native generation.
144
+
145
+ `session-access.js` commits the authority before writing the official
146
+ `session.update({ sessionID, title })`. A conditional metadata mutation checks
147
+ the revision captured before model generation, so a late model result cannot
148
+ undo a newer manual rename. Even output equal to the current title claims
149
+ ownership. Sidebar rename commits a new manual authority before its official
150
+ title update. Smart-title requests explicitly allow a new summary generation.
151
+ Ordinary follow-up messages still do not regenerate titles.
152
+
153
+ Native `session.renamed`, full session updates and Host metadata writes trigger
154
+ coalesced per-session reconciliation. It reads current authority and upstream
155
+ title, writes only on drift, then verifies again. Duplicate/out-of-order events
156
+ are hints, never authority. Retries use exponential backoff capped at 60 seconds;
157
+ 404 and shutdown stop pending work. Connection/startup recovery checks persisted
158
+ owned sessions serially, without model calls. Failed upstream writes retain the
159
+ durable intent and report failure rather than claiming successful synchronization.
160
+
161
+ List/detail and live/replayed rename projections retain the owned title while
162
+ repair runs; the real OpenCode `session.title` is repaired as well. The upstream
163
+ API has no atomic title lock, so other OpenCode clients can briefly observe a
164
+ conflicting write until repair completes. A running, connected Host is required
165
+ for repair; restart/reconnect recovers missed writes. Direct OpenCode renames
166
+ (manual or generated) cannot replace an owned title: rename in OpenChamber to
167
+ change it. Extension-only VS Code receives shared metadata/projection behavior,
168
+ but repair requires a connected OpenChamber Host.
138
169
 
139
170
  ## Limitations
140
171
 
141
172
  Title language follows the user's real message text. Assistant responses, tool
142
173
  output, and transcript labels provide context without selecting the title language.
143
174
 
175
+ Attachments do not gate title generation. Admission retains user text plus at
176
+ most eight bounded filename/MIME descriptors, never attachment bytes or URLs.
177
+ Attachment-only messages also start generation; the text-only summary model can
178
+ name the attachment subject from those descriptors, but is explicitly told that
179
+ file contents are not included. Transcript-based/manual generation uses the
180
+ same file descriptors. This does not perform image recognition or file parsing.
181
+
144
182
  - Lives in the web server, so VS Code (extension-only) does not generate
145
183
  refreshes; it still receives `session.updated` title changes produced by a
146
184
  web/desktop instance of the same OpenCode server.
147
- - First OpenCode-generated title (no `titleRefresh` metadata yet) may be
148
- replaced once the conversation has 2+ real user turns — that is intentional.
185
+ - First OpenCode-generated titles may be replaced by OpenChamber's initial title,
186
+ regardless of which model completes first.
@@ -55,6 +55,29 @@ it.each([false, true])('runs a Host smart-title request and clears loading (mode
55
55
  }
56
56
  });
57
57
 
58
+ it('does not start a pending initial title over a manual authority committed before generation', async () => {
59
+ vi.useFakeTimers();
60
+ const generate = vi.fn();
61
+ const session = { id: 'ses_manual', title: 'Manual', metadata: { openchamber: {
62
+ titleAuthority: { sessionID: 'ses_manual', title: 'Manual', revision: 'manual', source: 'manual' },
63
+ } } };
64
+ const runtime = createSessionTitleRuntime({
65
+ sessionAccess: {
66
+ get: async () => session,
67
+ messages: async () => [{ info: { id: 'u1', role: 'user' }, parts: [{ type: 'text', text: 'Initial work' }] }],
68
+ update: async () => {},
69
+ },
70
+ getSmallModelService: async () => ({ generateSmallModelText: generate }),
71
+ isTitleRefreshEnabled: () => true,
72
+ });
73
+ try {
74
+ runtime.processPayload({ type: 'session.created', data: { sessionID: session.id } });
75
+ runtime.processPayload({ type: 'session.execution.succeeded', data: { sessionID: session.id } });
76
+ await vi.runAllTimersAsync();
77
+ expect(generate).not.toHaveBeenCalled();
78
+ } finally { runtime.stop(); }
79
+ });
80
+
58
81
  it('uses the real v2 client and publishes the generated title without another session click', async () => {
59
82
  vi.useFakeTimers();
60
83
  configureServerOpenCodeFetchGate(null, { allowMissingContract: true });
@@ -85,6 +108,12 @@ it('uses the real v2 client and publishes the generated title without another se
85
108
  buildOpenCodeUrl: (path) => `http://opencode${path}`,
86
109
  getOpenCodeAuthHeaders: () => ({}),
87
110
  readSessionMetadata: async () => metadata,
111
+ mutateSessionMetadata: async (_id, decide) => {
112
+ const decision = decide(metadata);
113
+ if (!decision.ok) return { committed: false };
114
+ metadata = mergeMetadataPatch(metadata, decision.patch);
115
+ return { committed: true, metadata };
116
+ },
88
117
  persistSessionMetadata: async (_id, patch) => {
89
118
  metadata = mergeMetadataPatch(metadata, patch);
90
119
  events.push({ type: 'metadata', metadata: structuredClone(metadata) });
@@ -105,15 +134,30 @@ it('uses the real v2 client and publishes the generated title without another se
105
134
  expect(events.at(-1)).toMatchObject({ type: 'session', row: { title: 'Title refresh', directory: '/repo' } });
106
135
  expect(metadata.openchamber.titleRefresh.isGenerating).toBeUndefined();
107
136
  expect(requests).toContain('PATCH /api/session/ses_title');
137
+ session.title = 'Late native title';
138
+ runtime.processPayload({ type: 'session.renamed', created: 2000, data: {
139
+ sessionID: session.id, title: session.title,
140
+ } });
141
+ await vi.runAllTimersAsync();
142
+ expect(session.title).toBe('Title refresh');
143
+ expect(events.at(-1)).toMatchObject({ type: 'session', row: { title: 'Title refresh' } });
144
+ const writes = requests.filter((request) => request.startsWith('PATCH')).length;
145
+ runtime.processPayload({ type: 'session.renamed', created: 500, data: {
146
+ sessionID: session.id, title: 'Replayed stale title',
147
+ } });
148
+ await vi.runAllTimersAsync();
149
+ expect(requests.filter((request) => request.startsWith('PATCH'))).toHaveLength(writes);
108
150
  } finally {
109
151
  runtime.stop();
110
152
  }
111
153
  });
112
154
 
113
- it('starts the first title from admitted user text while the assistant is still running', async () => {
155
+ it.each(['new', 'missed-create', 'fork', 'missed-fork-create'].flatMap((scenario) =>
156
+ ['text', 'text-and-file', 'file-only'].map((content) => ({ scenario, content })),
157
+ ))('starts the first title before assistant completion ($scenario, $content)', async ({ scenario, content }) => {
114
158
  vi.useFakeTimers();
115
159
  const session = {
116
- id: 'ses_first', title: '', time: { created: 1, updated: 1 },
160
+ id: 'ses_first', title: scenario.includes('fork') ? 'Original (fork #1)' : '', time: { created: 1, updated: 1 },
117
161
  metadata: {},
118
162
  };
119
163
  let finishTitle;
@@ -135,26 +179,36 @@ it('starts the first title from admitted user text while the assistant is still
135
179
  });
136
180
  const emit = (type, data) => runtime.processPayload({ type, data, created: 1000, location: { directory: '/repo' } });
137
181
  try {
138
- emit('session.created', { sessionID: session.id, location: { directory: '/repo' } });
182
+ if (!scenario.startsWith('missed')) {
183
+ emit('session.created', { sessionID: session.id, title: session.title, time: session.time, location: { directory: '/repo' } });
184
+ }
139
185
  emit('session.inbox.enqueued', { sessionID: session.id, inboxID: 'msg_synthetic', item: {
140
186
  type: 'synthetic', delivery: 'steer', payload: { text: 'Internal instructions' },
141
187
  } });
142
188
  await vi.runAllTimersAsync();
143
189
  expect(generate).not.toHaveBeenCalled();
144
190
  emit('session.inbox.enqueued', { sessionID: session.id, inboxID: 'msg_first', item: {
145
- type: 'user', delivery: 'steer', payload: { text: 'Implement a session title generator' },
191
+ type: 'user', delivery: 'steer', payload: {
192
+ text: content === 'file-only' ? '' : 'Implement a session title generator',
193
+ ...(content !== 'text' ? { files: [{ name: 'title-bug.png', mime: 'image/png', source: { type: 'inline' }, data: 'YQ=='.repeat(100_000) }] } : {}),
194
+ },
146
195
  } });
147
196
  emit('session.execution.started', { sessionID: session.id });
148
197
  emit('session.status', { sessionID: session.id, status: { type: 'busy' } });
149
198
  await vi.runAllTimersAsync();
150
199
  expect(generate).toHaveBeenCalledTimes(1);
151
- expect(generate.mock.calls[0][0].prompt).toContain('Implement a session title generator');
200
+ const prompt = generate.mock.calls[0][0].prompt;
201
+ if (content !== 'file-only') expect(prompt).toContain('Implement a session title generator');
202
+ if (content !== 'text') expect(prompt).toContain('title-bug.png');
203
+ expect(prompt).not.toContain('YQ==');
204
+ expect(prompt.length).toBeLessThan(4000);
152
205
  expect(session.metadata.openchamber?.titleRefresh?.isGenerating).toBe(true);
153
206
  expect(access.messages).not.toHaveBeenCalled();
154
207
  finishTitle({ text: 'Session titles', providerID: 'test', modelID: 'test' });
155
208
  await vi.runAllTimersAsync();
156
209
  expect(session.title).toBe('Session titles');
157
210
  expect(session.metadata.openchamber.titleRefresh.isGenerating).toBeUndefined();
211
+ expect(access.messages).not.toHaveBeenCalled();
158
212
  emit('session.execution.succeeded', { sessionID: session.id });
159
213
  emit('session.inbox.enqueued', { sessionID: session.id, inboxID: 'msg_second', item: {
160
214
  type: 'user', delivery: 'steer', payload: { text: 'Follow up on the implementation' },
@@ -1,7 +1,7 @@
1
1
  // Session title refresh: regenerate the sidebar title from the conversation's
2
2
  // MAIN SUBJECT (overall feature / goal), not just the last wrap-up utterance.
3
3
  // OpenCode only titles once from the first user message. Auto refresh is sparse
4
- // for title stability: first idle of a new session, first newly-sent reply on a
4
+ // for title stability: first user admission of a new session, first send on a
5
5
  // fork, plus explicit smart-title / forced refresh. Continuing the same work
6
6
  // should keep naming the thing being done — not "commit and push". This
7
7
  // watcher throttles to at most one refresh per session every TITLE_THROTTLE_MS
@@ -46,8 +46,8 @@ const FORK_TITLE_RE = / \(fork #\d+\)$/;
46
46
  const MULTI_RUN_GROUP_SLUG = /^[a-z0-9](?:[a-z0-9-]{0,48}[a-z0-9])?$/;
47
47
  const MULTI_RUN_GROUP = /^g[1-9]\d*$/;
48
48
 
49
- // Hard generation switch (default on). When off, no small-model calls and no
50
- // title writes happen. Existing titles stay untouched.
49
+ // Generation switch (default on). Existing owned titles still reconcile when
50
+ // generation is off; explicit smart-title requests also remain available.
51
51
  const isSessionTitleRefreshEnabled = () => {
52
52
  try {
53
53
  const raw = fs.readFileSync(OPENCHAMBER_SETTINGS_FILE, 'utf8');
@@ -201,13 +201,22 @@ const extractUserMessage = (payload) => {
201
201
  const item = properties.item;
202
202
  if (typeof properties.sessionID !== 'string' || item?.type !== 'user') return null;
203
203
  const text = typeof item.payload?.text === 'string' ? item.payload.text.slice(0, TRANSCRIPT_USER_CHAR_LIMIT) : '';
204
+ const files = Array.isArray(item.payload?.files) ? item.payload.files.slice(0, 8) : [];
205
+ const parts = [
206
+ ...(text.trim() ? [{ type: 'text', text }] : []),
207
+ ...files.filter((file) => file && typeof file === 'object').map((file) => ({
208
+ type: 'file',
209
+ filename: typeof file.name === 'string' ? file.name.slice(0, 200) : '',
210
+ mime: typeof file.mime === 'string' ? file.mime.slice(0, 100) : '',
211
+ })),
212
+ ];
204
213
  return {
205
214
  sessionId: properties.sessionID,
206
215
  createdAt: payload.created ?? 0,
207
216
  directory: payload.location?.directory ?? '',
208
- initialRecord: text.trim() ? {
217
+ initialRecord: parts.length ? {
209
218
  info: { id: properties.inboxID, role: 'user' },
210
- parts: [{ type: 'text', text }],
219
+ parts,
211
220
  } : null,
212
221
  };
213
222
  }
@@ -230,7 +239,13 @@ const messagePartsToText = (message) => {
230
239
  ? TRANSCRIPT_ASSISTANT_CHAR_LIMIT
231
240
  : TRANSCRIPT_USER_CHAR_LIMIT;
232
241
  return parts
233
- .map((part) => (part?.type === 'text' && typeof part.text === 'string' ? part.text : ''))
242
+ .map((part) => {
243
+ if (part?.type === 'text' && typeof part.text === 'string') return part.text;
244
+ if (part?.type !== 'file') return '';
245
+ const filename = typeof part.filename === 'string' ? part.filename.slice(0, 200) : '';
246
+ const mime = typeof part.mime === 'string' ? part.mime.slice(0, 100) : '';
247
+ return `[Attachment: ${filename || mime || 'file'}${filename && mime ? ` (${mime})` : ''}; contents not included]`;
248
+ })
234
249
  .filter(Boolean)
235
250
  .join('\n')
236
251
  .slice(0, charLimit);
@@ -495,7 +510,7 @@ export const createSessionTitleRuntime = ({
495
510
  });
496
511
  };
497
512
 
498
- const recordUserActivity = async (sessionId, directory, createdAt) => {
513
+ const recordUserActivity = async (sessionId, directory, createdAt, initialRecord) => {
499
514
  if (!Number.isFinite(createdAt) || createdAt <= 0) return;
500
515
  const session = await openCodeFetch(`/session/${encodeURIComponent(sessionId)}`, { directory })
501
516
  .catch(() => null);
@@ -508,7 +523,7 @@ export const createSessionTitleRuntime = ({
508
523
  // never learned about the fork. The first newly-created user message on a
509
524
  // fork-titled session (message created after the fork, before any activity
510
525
  // timestamp advanced past the fork) rebuilds that pending entry so the
511
- // matching idle still triggers the first-refresh. Idempotent: the normal
526
+ // first admission can trigger the refresh. Idempotent: the normal
512
527
  // session.created path already holds a pending entry, and once activity
513
528
  // advances past the fork time later messages never re-register.
514
529
  const forkCreated = Number(session?.time?.created);
@@ -526,6 +541,26 @@ export const createSessionTitleRuntime = ({
526
541
  });
527
542
  }
528
543
 
544
+ // Recover first-send admission even when session.created was missed.
545
+ // Use the admitted text: the message projection may not exist yet.
546
+ if (initialRecord && !stopped && !inflight.has(sessionId) && !initialRefreshes.has(sessionId)
547
+ && !forkFirstRefreshes.has(sessionId) && !session.parentID && !isSystemOwnedSession(session)) {
548
+ const pendingFork = forkFirstSendPending.get(sessionId);
549
+ const firstForkSend = pendingFork && createdAt > pendingFork.createdAt;
550
+ const untitled = !session.title || isDefaultSessionTitle(session.title);
551
+ if (firstForkSend || (untitled && !meta.generatedAt && !meta.lastAutoTitle)) {
552
+ newSessions.delete(sessionId);
553
+ if (firstForkSend) {
554
+ forkFirstSendPending.delete(sessionId);
555
+ forkFirstRefreshes.add(sessionId);
556
+ } else {
557
+ initialRefreshes.add(sessionId);
558
+ }
559
+ initialUserMessages.set(sessionId, initialRecord);
560
+ armTimer(sessionId, directory, 0);
561
+ }
562
+ }
563
+
529
564
  const previous = Number(meta.titleRefresh.activityUpdatedAt);
530
565
  const activityUpdatedAt = Number.isFinite(previous) ? Math.max(previous, createdAt) : createdAt;
531
566
  if (activityUpdatedAt === previous) return;
@@ -568,6 +603,8 @@ export const createSessionTitleRuntime = ({
568
603
 
569
604
  const currentTitle = typeof session.title === 'string' ? session.title : '';
570
605
  if (looksLikeMultiRunSessionTitle(currentTitle)) return;
606
+ const authority = session.metadata?.openchamber?.titleAuthority;
607
+ if (!forceRefresh && authority?.sessionID === sessionId && authority.source === 'manual') return;
571
608
 
572
609
  const meta = readTitleRefreshMeta(session);
573
610
  const canRefreshForkTitle = forkFirstRefresh && isForkedSessionTitle(currentTitle);
@@ -665,12 +702,7 @@ export const createSessionTitleRuntime = ({
665
702
  }
666
703
 
667
704
  const nextTitle = cleanGeneratedTitle(generated?.text);
668
- if (!nextTitle) return;
669
- if (nextTitle === currentTitle) {
670
- // Still stamp metadata so we do not keep re-calling for the same tail.
671
- lastGeneratedAtBySession.set(sessionId, now());
672
- return;
673
- }
705
+ if (!nextTitle || stopped) return;
674
706
 
675
707
  // Re-check before write: user may have renamed, or a new message arrived.
676
708
  const freshSession = await openCodeFetch(`/session/${encodeURIComponent(sessionId)}`, { directory })
@@ -683,7 +715,7 @@ export const createSessionTitleRuntime = ({
683
715
  return;
684
716
  }
685
717
 
686
- const latestMessages = await fetchRecentMessages(sessionId, directory);
718
+ const latestMessages = initialUserMessage ? null : await fetchRecentMessages(sessionId, directory);
687
719
  const latestAssistantId = (() => {
688
720
  if (!latestMessages) return null;
689
721
  for (let i = latestMessages.length - 1; i >= 0; i -= 1) {
@@ -707,6 +739,8 @@ export const createSessionTitleRuntime = ({
707
739
  method: 'PATCH',
708
740
  body: {
709
741
  title: nextTitle,
742
+ expectedTitleRevision: session.metadata?.openchamber?.titleAuthority?.sessionID === sessionId
743
+ ? session.metadata.openchamber.titleAuthority.revision : null,
710
744
  metadata: {
711
745
  openchamber: {
712
746
  titleRefresh: {
@@ -742,6 +776,16 @@ export const createSessionTitleRuntime = ({
742
776
  const processPayload = (payload, directoryHint = '') => {
743
777
  if (stopped) return;
744
778
  directoryHint = directoryHint || payload?.location?.directory || '';
779
+ if (payload?.type === 'server.connected') {
780
+ void sessionAccess?.reconcileAll?.().catch((error) => {
781
+ console.warn('[session-title] recovery failed:', error?.message || error);
782
+ });
783
+ }
784
+ if (payload?.type === 'session.renamed' || payload?.type === 'session.updated'
785
+ || payload?.type === 'openchamber:session-metadata') {
786
+ const data = payload.data ?? payload.properties ?? {};
787
+ sessionAccess?.reconcile?.(data.sessionID ?? data.info?.id ?? data.id, directoryHint);
788
+ }
745
789
  const createdSession = extractCreatedSession(payload);
746
790
  if (createdSession) {
747
791
  if (isForkedSessionTitle(createdSession.title)) {
@@ -777,7 +821,7 @@ export const createSessionTitleRuntime = ({
777
821
  initialRefreshes.add(status.sessionId);
778
822
  armTimer(status.sessionId, status.directory || directoryHint, 0);
779
823
  }
780
- } else if (!initialRefreshes.has(status.sessionId)) {
824
+ } else if (!initialRefreshes.has(status.sessionId) && !forkFirstRefreshes.has(status.sessionId)) {
781
825
  clearTimer(status.sessionId);
782
826
  }
783
827
  return;
@@ -797,6 +841,7 @@ export const createSessionTitleRuntime = ({
797
841
  userMessage.sessionId,
798
842
  userMessage.directory || directoryHint,
799
843
  userMessage.createdAt,
844
+ userMessage.initialRecord,
800
845
  ).catch((error) => {
801
846
  console.warn('[session-title] failed to record user activity:', error?.message || error);
802
847
  });
@@ -804,7 +849,7 @@ export const createSessionTitleRuntime = ({
804
849
  // session settles. Only a message created after the timer was armed
805
850
  // means the user actually moved on.
806
851
  const armed = timers.get(userMessage.sessionId);
807
- if (armed && !initialRefreshes.has(userMessage.sessionId) && userMessage.createdAt >= armed.armedAt) {
852
+ if (armed && !initialRefreshes.has(userMessage.sessionId) && !forkFirstRefreshes.has(userMessage.sessionId) && userMessage.createdAt >= armed.armedAt) {
808
853
  clearTimer(userMessage.sessionId);
809
854
  }
810
855
  }
@@ -812,6 +857,7 @@ export const createSessionTitleRuntime = ({
812
857
 
813
858
  const stop = () => {
814
859
  stopped = true;
860
+ sessionAccess?.stop?.();
815
861
  for (const { timer } of timers.values()) {
816
862
  clearTimeout(timer);
817
863
  }
@@ -1,5 +1,6 @@
1
1
  import { makeOpenCodeV2Client } from '../opencode/v2-client.js';
2
- import { projectSessionWithHostMetadata } from '../session-metadata/session-projection.js';
2
+ import { projectSessionWithHostMetadata, readSessionTitleAuthority } from '../session-metadata/session-projection.js';
3
+ import { randomUUID } from 'node:crypto';
3
4
  import { projectSessionMessageRecords } from '../session-turn-pages/session-message-projection.js';
4
5
 
5
6
  /** Title reads use OpenCode; title-refresh metadata and its notifications belong to the Host. */
@@ -8,8 +9,15 @@ export function createTitleSessionAccess({
8
9
  getOpenCodeAuthHeaders,
9
10
  readSessionMetadata,
10
11
  persistSessionMetadata,
12
+ mutateSessionMetadata,
13
+ listSessionMetadata = async () => ({}),
11
14
  publishSession,
12
15
  }) {
16
+ const pending = new Map();
17
+ let stopped = false;
18
+ let recovery = null;
19
+ let recoveryTimer = null;
20
+ let recoveryFailures = 0;
13
21
  const client = () => makeOpenCodeV2Client({
14
22
  baseUrl: buildOpenCodeUrl('/', '').replace(/\/$/, ''),
15
23
  authHeaders: getOpenCodeAuthHeaders(),
@@ -17,23 +25,120 @@ export function createTitleSessionAccess({
17
25
  const options = () => ({ signal: AbortSignal.timeout(30_000) });
18
26
  const get = async (sessionID) => {
19
27
  const session = await client().session.get({ sessionID }, options());
20
- return projectSessionWithHostMetadata(session, await readSessionMetadata(sessionID));
28
+ const metadata = await readSessionMetadata(sessionID);
29
+ const authority = readSessionTitleAuthority(sessionID, metadata);
30
+ if (authority && authority.title !== session.title) reconcile(sessionID);
31
+ return projectSessionWithHostMetadata(session, metadata);
32
+ };
33
+
34
+ // Coalesce only pending work; settled sessions retain no in-memory entry.
35
+ const reconcile = (sessionID, directory = '') => {
36
+ if (stopped || !sessionID) return;
37
+ const existing = pending.get(sessionID);
38
+ if (existing) { existing.dirty = true; return; }
39
+ const state = { dirty: false, timer: null, failures: 0, repaired: false };
40
+ pending.set(sessionID, state);
41
+ const run = async () => {
42
+ state.dirty = false;
43
+ try {
44
+ const metadata = await readSessionMetadata(sessionID);
45
+ const authority = readSessionTitleAuthority(sessionID, metadata);
46
+ if (!authority || stopped) { pending.delete(sessionID); return; }
47
+ const session = await client().session.get({ sessionID }, options());
48
+ if (stopped) return;
49
+ if (session.title !== authority.title) {
50
+ await client().session.update({ sessionID, title: authority.title }, options());
51
+ state.repaired = true;
52
+ // Always verify after a write, even if its echo was lost or reordered.
53
+ state.dirty = true;
54
+ } else {
55
+ const latest = await readSessionMetadata(sessionID);
56
+ if (readSessionTitleAuthority(sessionID, latest)?.revision !== authority.revision) {
57
+ state.dirty = true;
58
+ } else if (state.repaired && !stopped) {
59
+ state.repaired = false;
60
+ await publishSession(projectSessionWithHostMetadata(session, latest), directory);
61
+ }
62
+ }
63
+ state.failures = 0;
64
+ } catch (error) {
65
+ if (Number(error?.status ?? error?.statusCode) === 404 || error?._tag === 'SessionNotFoundError') {
66
+ pending.delete(sessionID);
67
+ return;
68
+ }
69
+ state.failures += 1;
70
+ state.dirty = true;
71
+ if (state.failures === 1) console.warn('[session-title] reconciliation failed:', error?.message || error);
72
+ } finally {
73
+ if (stopped) { pending.delete(sessionID); return; }
74
+ if (pending.get(sessionID) !== state) return;
75
+ if (state.dirty) {
76
+ state.timer = setTimeout(run, state.failures ? Math.min(60_000, 1000 * 2 ** Math.min(state.failures - 1, 6)) : 0);
77
+ state.timer.unref?.();
78
+ } else pending.delete(sessionID);
79
+ }
80
+ };
81
+ state.timer = setTimeout(run, 0);
82
+ state.timer.unref?.();
83
+ };
84
+ const reconcileAll = () => {
85
+ if (stopped) return Promise.resolve();
86
+ if (recovery) return recovery;
87
+ clearTimeout(recoveryTimer);
88
+ recovery = (async () => {
89
+ const entries = await listSessionMetadata();
90
+ recoveryFailures = 0;
91
+ for (const [id, metadata] of Object.entries(entries)) {
92
+ if (stopped) return;
93
+ if (!readSessionTitleAuthority(id, metadata)) continue;
94
+ // Serial discovery bounds reconnect reads; only drift queues repair.
95
+ try { await get(id); } catch { reconcile(id); }
96
+ }
97
+ })().catch((error) => {
98
+ recoveryFailures += 1;
99
+ if (!stopped) {
100
+ recoveryTimer = setTimeout(() => { void reconcileAll(); }, Math.min(60_000, 1000 * 2 ** Math.min(recoveryFailures - 1, 6)));
101
+ recoveryTimer.unref?.();
102
+ }
103
+ if (recoveryFailures === 1) console.warn('[session-title] recovery failed:', error?.message || error);
104
+ }).finally(() => { recovery = null; });
105
+ return recovery;
21
106
  };
22
107
  return {
23
108
  get,
109
+ reconcile,
110
+ reconcileAll,
111
+ stop() {
112
+ stopped = true;
113
+ clearTimeout(recoveryTimer);
114
+ for (const state of pending.values()) clearTimeout(state.timer);
115
+ pending.clear();
116
+ },
24
117
  async messages(sessionID, _directory, limit) {
25
118
  const page = await client().message.list({ sessionID, limit, order: 'desc' }, options());
26
119
  if (!Array.isArray(page?.data)) throw new Error('Session title: invalid message page');
27
120
  return projectSessionMessageRecords([...page.data].reverse());
28
121
  },
29
122
  async update(sessionID, directory, patch) {
123
+ if (stopped) return;
30
124
  if (typeof patch.title === 'string') {
125
+ const result = await mutateSessionMetadata(sessionID, (metadata) => {
126
+ const current = readSessionTitleAuthority(sessionID, metadata);
127
+ if ((current?.revision ?? null) !== (patch.expectedTitleRevision ?? null)) return { ok: false };
128
+ return { ok: true, patch: { openchamber: {
129
+ titleAuthority: { sessionID, title: patch.title, revision: randomUUID(), source: 'summary' },
130
+ titleRefresh: patch.metadata?.openchamber?.titleRefresh ?? {},
131
+ } } };
132
+ });
133
+ if (!result.committed || stopped) return;
134
+ // Durable intent survives failed writes and process restarts.
135
+ reconcile(sessionID, directory);
31
136
  await client().session.update({ sessionID, title: patch.title }, options());
32
137
  }
33
138
  // Never write the complete metadata snapshot back: goal/archive/ownership
34
139
  // may have changed while the title model was running.
35
140
  const refresh = patch.metadata?.openchamber?.titleRefresh;
36
- if (refresh) {
141
+ if (refresh && typeof patch.title !== 'string') {
37
142
  await persistSessionMetadata(sessionID, { openchamber: { titleRefresh: refresh } });
38
143
  }
39
144
  if (typeof patch.title === 'string' || (refresh && Object.hasOwn(refresh, 'isGenerating'))) {