@openchambery/web 2.0.0-beta.26 → 2.0.0-beta.29

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 (194) hide show
  1. package/README.md +14 -0
  2. package/dist/assets/{AgentAvatar-FyTdEOMr.js → AgentAvatar-O2x_1cFU.js} +1 -1
  3. package/dist/assets/{AssistantView-DvOO2Kf5.js → AssistantView-Bphg-QIn.js} +3 -3
  4. package/dist/assets/{DiagramView-BqjeSOIg.js → DiagramView-CRD_GFch.js} +1 -1
  5. package/dist/assets/{FadeInOnReveal-DHDffzHs.js → FadeInOnReveal-CMBd5BTd.js} +1 -1
  6. package/dist/assets/{Icon-D7NdMmbi.js → Icon-BN87CyS4.js} +1 -1
  7. package/dist/assets/{MarkdownRenderer-kpkRqS42.js → MarkdownRenderer-D5KEGdBn.js} +2 -2
  8. package/dist/assets/{MarkdownRendererImpl-CzBos-Gt.js → MarkdownRendererImpl-C143YQsV.js} +2 -2
  9. package/dist/assets/{MarkstreamRendererImpl-RY_rAgKD.js → MarkstreamRendererImpl-C4rgOuOP.js} +1 -1
  10. package/dist/assets/{MobileDetailNavigation-DgtRh0zu.js → MobileDetailNavigation-D5tcF2Kg.js} +1 -1
  11. package/dist/assets/{MobileShareBridge-BM3cN-gG.js → MobileShareBridge-Bm0JZKHc.js} +3 -3
  12. package/dist/assets/MobileSurface-BzzBCVkG.js +2 -0
  13. package/dist/assets/{MultiRunWindow-Bq4gz78C.js → MultiRunWindow-B6Sb3aEG.js} +1 -1
  14. package/dist/assets/{OnboardingScreen-DMbPMwlp.js → OnboardingScreen-BLjZIBOs.js} +1 -1
  15. package/dist/assets/{OverlayScrollbar-mI9WVa7E.js → OverlayScrollbar-BOqRklf0.js} +1 -1
  16. package/dist/assets/SettingsView-Ds6x2z0F.js +25 -0
  17. package/dist/assets/{SettingsWindow-CHPjbEnI.js → SettingsWindow-D6CN5ahJ.js} +1 -1
  18. package/dist/assets/{TerminalView-DCJuL2nP.js → TerminalView-DkzaJJfh.js} +1 -1
  19. package/dist/assets/{ToolOutputDialog-Cg_Q1t3X.js → ToolOutputDialog-BegFpg1h.js} +1 -1
  20. package/dist/assets/{appThemeRegistry-BxuflaQt.js → appThemeRegistry-BMzuqD8_.js} +1 -1
  21. package/dist/assets/{button-e8fYWIMM.js → button-Ck8ipzBm.js} +1 -1
  22. package/dist/assets/{collapsible-B6wLXHi3.js → collapsible-nU99prkD.js} +1 -1
  23. package/dist/assets/{context-menu-Bq7W1d7y.js → context-menu-DMzZN1oi.js} +1 -1
  24. package/dist/assets/{desktopHosts-C8vf9vRY.js → desktopHosts-DuftEj8B.js} +1 -1
  25. package/dist/assets/{desktopRelayRestore-DDJtljeu.js → desktopRelayRestore-g378BdZf.js} +1 -1
  26. package/dist/assets/{diagram-BBL-LfsC.js → diagram-CvO4P8ds.js} +1 -1
  27. package/dist/assets/{dialog-MRsJpHzu.js → dialog-ByGUkS3c.js} +1 -1
  28. package/dist/assets/{es-DvPwAj0y.js → es-DCjl24dV.js} +3 -3
  29. package/dist/assets/fileMentionTouchSelection-Cuk9C_jN.js +2 -0
  30. package/dist/assets/{fileQueries-CBu-ytXS.js → fileQueries-DuF89nFO.js} +1 -1
  31. package/dist/assets/{fr-XQsoaNKY.js → fr-DfJ5j0t5.js} +3 -3
  32. package/dist/assets/{gitApi-CuIUGt04.js → gitApi-BlWYyRdx.js} +3 -3
  33. package/dist/assets/{input-DbOe1mdT.js → input-CXhFmHcq.js} +1 -1
  34. package/dist/assets/insertionBoundaries-lAwXo-R-.js +1 -0
  35. package/dist/assets/{ja-DslzO96s.js → ja-B_9_w2Xf.js} +3 -3
  36. package/dist/assets/{ko-C_9XOAL0.js → ko-DlGbo3kV.js} +3 -3
  37. package/dist/assets/{main-Sd4Auh_x.js → main-BmiKRvkB.js} +2 -2
  38. package/dist/assets/main-D5SdJE5d.js +169 -0
  39. package/dist/assets/main-qSeeJRW1.css +1 -0
  40. package/dist/assets/{markdownHeightCache-B5VSLl79.js → markdownHeightCache-g_UsWzbA.js} +1 -1
  41. package/dist/assets/{mcpQueries-BYNrkehM.js → mcpQueries-0Oe-8vHC.js} +1 -1
  42. package/dist/assets/{miniChat-Hp9bNmS0.js → miniChat-CSwFHa9K.js} +2 -2
  43. package/dist/assets/mobile-DzJO7usb.js +2 -0
  44. package/dist/assets/multirun-Bdd0yE3M.js +3 -0
  45. package/dist/assets/persistence-Dnj1QOdX.js +3 -0
  46. package/dist/assets/{pl-Dst9Wk53.js → pl-DjUsS8Ei.js} +3 -3
  47. package/dist/assets/platform-pfYy32vr.js +6 -0
  48. package/dist/assets/projectMeta-2dbON46t.js +1 -0
  49. package/dist/assets/{pt-BR-Dq394BPb.js → pt-BR-B8sV7qo1.js} +3 -3
  50. package/dist/assets/renderElectronMiniChatApp-Bjf5yS-2.js +2 -0
  51. package/dist/assets/renderMobileApp-Bv0VxQo1.js +28 -0
  52. package/dist/assets/{runtimeConfig-pTYamMLy.js → runtimeConfig-BOk04YEP.js} +1 -1
  53. package/dist/assets/runtimeEndpointReset-DsuP5FPT.css +1 -0
  54. package/dist/assets/runtimeEndpointReset-Oo4f7T5P.js +148 -0
  55. package/dist/assets/{sessionChangeSummary-DcnZyuWt.js → sessionChangeSummary-Bf5wJLSv.js} +2 -2
  56. package/dist/assets/{sessionLookup-B3t-FerN.js → sessionLookup-pga3-Etf.js} +1 -1
  57. package/dist/assets/{sortable-tabs-strip-B0sZ7mm6.js → sortable-tabs-strip-CHqMkTI3.js} +1 -1
  58. package/dist/assets/{store-D8fmTWK3.js → store-Cuktref-.js} +3 -3
  59. package/dist/assets/{terminalApi-CJyWCuc8.js → terminalApi-BgGraKxs.js} +1 -1
  60. package/dist/assets/{textarea-B5GMntHk.js → textarea-B0Od84ZM.js} +1 -1
  61. package/dist/assets/{tooltip-C64SdRvR.js → tooltip-BKL7J5hx.js} +1 -1
  62. package/dist/assets/{uk-8XAR75Qh.js → uk-Bh7HXyLp.js} +3 -3
  63. package/dist/assets/{url-DF1NFwt_.js → url-DNRMq_0R.js} +1 -1
  64. package/dist/assets/{useAppFontEffects-BQklk6XB.js → useAppFontEffects-gMl7z7An.js} +53 -53
  65. package/dist/assets/{useAssistantUIStore-B75cQm_z.js → useAssistantUIStore-DBwfg9nX.js} +1 -1
  66. package/dist/assets/useDesktopWindowControlsLayout-GOgauAay.js +1 -0
  67. package/dist/assets/{useEffectiveDirectory-Ddx7sZ5A.js → useEffectiveDirectory-DgAB7HWq.js} +1 -1
  68. package/dist/assets/{useFontPreferences-CrteQiAp.js → useFontPreferences-Djxu_uRy.js} +1 -1
  69. package/dist/assets/useMobileNavigationStore-D7uAoBXe.js +415 -0
  70. package/dist/assets/useSessionAutoCleanup-Cg7KJFGa.js +2 -0
  71. package/dist/assets/{useUIStore-Brsa2mKs.js → useUIStore-Dfx6TKvi.js} +1 -1
  72. package/dist/assets/{useWorkerHighlightedLines-VOtVUGkO.js → useWorkerHighlightedLines-DKa2EWBH.js} +1 -1
  73. package/dist/assets/vendor-opencode-client-MF2GBrFx.js +12 -0
  74. package/dist/assets/{vendor-zustand-B4dlGEM2.js → vendor-zustand-DzRqcymP.js} +1 -1
  75. package/dist/assets/{zh-CN-B-ILfrqC.js → zh-CN-Uzia0gk4.js} +3 -3
  76. package/dist/assets/{zh-TW-5HeTVP4z.js → zh-TW-CiI7DLdB.js} +3 -3
  77. package/dist/index.html +10 -10
  78. package/dist/mini-chat.html +10 -10
  79. package/dist/mobile.html +10 -10
  80. package/package.json +3 -3
  81. package/server/index.js +8 -20
  82. package/server/lib/git/DOCUMENTATION.md +1 -0
  83. package/server/lib/git/routes.js +7 -4
  84. package/server/lib/git/routes.test.js +34 -0
  85. package/server/lib/git/service.js +3 -1
  86. package/server/lib/llm/DOCUMENTATION.md +6 -4
  87. package/server/lib/llm/generate.js +8 -9
  88. package/server/lib/llm/generate.test.js +33 -6
  89. package/server/lib/opencode/DOCUMENTATION.md +24 -20
  90. package/server/lib/opencode/agents.js +13 -22
  91. package/server/lib/opencode/auth-state-runtime.js +3 -3
  92. package/server/lib/opencode/auth-state-runtime.test.js +59 -0
  93. package/server/lib/opencode/auth.d.ts +13 -0
  94. package/server/lib/opencode/auth.js +74 -64
  95. package/server/lib/opencode/auth.test.js +57 -0
  96. package/server/lib/opencode/commands.js +1 -1
  97. package/server/lib/opencode/config-entities-layering.test.js +72 -0
  98. package/server/lib/opencode/config-entity-routes.js +9 -122
  99. package/server/lib/opencode/config-entity-routes.test.js +7 -11
  100. package/server/lib/opencode/config-files.d.ts +17 -0
  101. package/server/lib/opencode/config-files.js +187 -0
  102. package/server/lib/opencode/config-files.test.js +201 -0
  103. package/server/lib/opencode/core-routes.js +0 -1
  104. package/server/lib/opencode/core-routes.test.js +0 -15
  105. package/server/lib/opencode/credential-boundary.d.ts +1 -0
  106. package/server/lib/opencode/credential-boundary.js +11 -0
  107. package/server/lib/opencode/ensure-cli.js +4 -1
  108. package/server/lib/opencode/env-config.js +4 -1
  109. package/server/lib/opencode/env-config.test.js +28 -0
  110. package/server/lib/opencode/env-runtime.test.js +2 -1
  111. package/server/lib/opencode/feature-routes-runtime.js +9 -7
  112. package/server/lib/opencode/hmr-state-runtime.js +4 -4
  113. package/server/lib/opencode/lifecycle.js +1 -49
  114. package/server/lib/opencode/lifecycle.test.js +30 -4
  115. package/server/lib/opencode/location-release.d.ts +8 -0
  116. package/server/lib/opencode/location-release.js +32 -0
  117. package/server/lib/opencode/location-release.test.js +43 -0
  118. package/server/lib/opencode/mcp.js +1 -1
  119. package/server/lib/opencode/opencode2-pin.js +2 -1
  120. package/server/lib/opencode/owned-runtime-upgrade.test.js +5 -5
  121. package/server/lib/opencode/plugin-routes.test.js +4 -2
  122. package/server/lib/opencode/plugins.js +12 -60
  123. package/server/lib/opencode/plugins.test.js +5 -3
  124. package/server/lib/opencode/providers.js +6 -3
  125. package/server/lib/opencode/proxy.js +7 -0
  126. package/server/lib/opencode/proxy.test.js +13 -0
  127. package/server/lib/opencode/routes.js +5 -5
  128. package/server/lib/opencode/routes.test.js +18 -18
  129. package/server/lib/opencode/runtime-contract.js +3 -2
  130. package/server/lib/opencode/runtime-contract.test.js +12 -12
  131. package/server/lib/opencode/shared.js +6 -175
  132. package/server/lib/opencode/upgrade-screen.js +4 -11
  133. package/server/lib/opencode/upgrade-screen.test.js +25 -25
  134. package/server/lib/opencode/version-alignment.test.js +40 -0
  135. package/server/lib/quota/DOCUMENTATION.md +9 -0
  136. package/server/lib/quota/provider-credentials.test.js +29 -0
  137. package/server/lib/quota/providers/claude.js +4 -4
  138. package/server/lib/quota/providers/codex.js +4 -4
  139. package/server/lib/quota/providers/codex.test.js +1 -1
  140. package/server/lib/quota/providers/copilot.js +5 -5
  141. package/server/lib/quota/providers/google/auth.js +3 -3
  142. package/server/lib/quota/providers/google/index.js +2 -2
  143. package/server/lib/quota/providers/index.js +3 -7
  144. package/server/lib/quota/providers/index.test.js +2 -2
  145. package/server/lib/quota/providers/kimi.js +4 -4
  146. package/server/lib/quota/providers/minimax-shared.js +4 -4
  147. package/server/lib/quota/providers/nanogpt.js +4 -4
  148. package/server/lib/quota/providers/openai.js +4 -4
  149. package/server/lib/quota/providers/openrouter.js +4 -4
  150. package/server/lib/quota/providers/wafer.js +4 -4
  151. package/server/lib/quota/providers/zai.js +4 -4
  152. package/server/lib/quota/providers/zhipuai-coding-plan.js +6 -20
  153. package/server/lib/quota/routes.js +1 -1
  154. package/server/lib/scheduled-tasks/DOCUMENTATION.md +1 -0
  155. package/server/lib/scheduled-tasks/runtime.js +2 -3
  156. package/server/lib/session-index/DOCUMENTATION.md +7 -0
  157. package/server/lib/session-index/event-ingest.js +13 -0
  158. package/server/lib/session-index/event-ingest.test.js +7 -0
  159. package/server/lib/session-index/service.js +45 -0
  160. package/server/lib/session-index/service.test.js +47 -0
  161. package/server/lib/session-metadata/DOCUMENTATION.md +6 -0
  162. package/server/lib/session-metadata/session-metadata-store.test.js +23 -0
  163. package/server/lib/session-metadata/session-projection.d.ts +1 -0
  164. package/server/lib/session-metadata/session-projection.js +23 -0
  165. package/server/lib/session-metadata/session-projection.test.js +35 -0
  166. package/server/lib/session-title/DOCUMENTATION.md +36 -8
  167. package/server/lib/session-title/native-runtime.test.js +42 -0
  168. package/server/lib/session-title/runtime.js +18 -8
  169. package/server/lib/session-title/session-access.js +108 -3
  170. package/server/lib/session-title/session-access.test.js +125 -7
  171. package/server/lib/tts/DOCUMENTATION.md +7 -5
  172. package/server/lib/tts/credential-source.test.js +23 -0
  173. package/server/lib/tts/routes.js +2 -2
  174. package/server/lib/tts/service.js +16 -37
  175. package/dist/assets/MobileSurface-ocVWenHA.js +0 -2
  176. package/dist/assets/SettingsView-CIZz40Jg.js +0 -25
  177. package/dist/assets/fileMentionTouchSelection-Bj67pPo7.js +0 -3
  178. package/dist/assets/insertionBoundaries-nzs1Q3YR.js +0 -1
  179. package/dist/assets/main-D2BzpnUt.css +0 -1
  180. package/dist/assets/main-DjilfYN7.js +0 -169
  181. package/dist/assets/mobile-fBLEOx9_.js +0 -2
  182. package/dist/assets/multirun-D7W7oCj4.js +0 -4
  183. package/dist/assets/persistence-CMWO3jFo.js +0 -3
  184. package/dist/assets/platform-B0p43squ.js +0 -6
  185. package/dist/assets/projectMeta-DrUKrYt0.js +0 -3
  186. package/dist/assets/renderElectronMiniChatApp-D1xipCO5.js +0 -2
  187. package/dist/assets/renderMobileApp-DSk7Qs-_.js +0 -28
  188. package/dist/assets/runtimeEndpointReset-B5HEMuiB.js +0 -149
  189. package/dist/assets/useDesktopWindowControlsLayout-CrzYwZDQ.js +0 -1
  190. package/dist/assets/useMobileNavigationStore-i4cXNoOf.js +0 -414
  191. package/dist/assets/useSessionAutoCleanup-Dzd0fGSe.js +0 -2
  192. package/dist/assets/vendor-opencode-client-C12gfjag.js +0 -12
  193. package/server/lib/opencode/snippets.js +0 -242
  194. 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
@@ -127,6 +127,8 @@ and other session updates keep the existing ordering timestamp unchanged.
127
127
  `sessionTitleRefreshEnabled` in OpenChamber settings (Settings → Chat,
128
128
  default on) controls background title refreshes. Explicit smart-title actions
129
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.
130
132
 
131
133
  Settings → Summary AI controls the title model and the optional
132
134
  `summarySessionTitlePrompt` override. The configured custom API receives the
@@ -134,10 +136,36 @@ title transcript only after the user explicitly selects that source.
134
136
 
135
137
  ## Manual rename contract
136
138
 
137
- After this module writes a title, further auto-refreshes only proceed while
138
- `session.title === titleRefresh.lastAutoTitle` (or the title is still the
139
- OpenCode default). Renaming in the sidebar breaks that equality and stops
140
- 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.
141
169
 
142
170
  ## Limitations
143
171
 
@@ -154,5 +182,5 @@ same file descriptors. This does not perform image recognition or file parsing.
154
182
  - Lives in the web server, so VS Code (extension-only) does not generate
155
183
  refreshes; it still receives `session.updated` title changes produced by a
156
184
  web/desktop instance of the same OpenCode server.
157
- - First OpenCode-generated title (no `titleRefresh` metadata yet) may be
158
- 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,6 +134,19 @@ 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
  }
@@ -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');
@@ -603,6 +603,8 @@ export const createSessionTitleRuntime = ({
603
603
 
604
604
  const currentTitle = typeof session.title === 'string' ? session.title : '';
605
605
  if (looksLikeMultiRunSessionTitle(currentTitle)) return;
606
+ const authority = session.metadata?.openchamber?.titleAuthority;
607
+ if (!forceRefresh && authority?.sessionID === sessionId && authority.source === 'manual') return;
606
608
 
607
609
  const meta = readTitleRefreshMeta(session);
608
610
  const canRefreshForkTitle = forkFirstRefresh && isForkedSessionTitle(currentTitle);
@@ -700,12 +702,7 @@ export const createSessionTitleRuntime = ({
700
702
  }
701
703
 
702
704
  const nextTitle = cleanGeneratedTitle(generated?.text);
703
- if (!nextTitle) return;
704
- if (nextTitle === currentTitle) {
705
- // Still stamp metadata so we do not keep re-calling for the same tail.
706
- lastGeneratedAtBySession.set(sessionId, now());
707
- return;
708
- }
705
+ if (!nextTitle || stopped) return;
709
706
 
710
707
  // Re-check before write: user may have renamed, or a new message arrived.
711
708
  const freshSession = await openCodeFetch(`/session/${encodeURIComponent(sessionId)}`, { directory })
@@ -742,6 +739,8 @@ export const createSessionTitleRuntime = ({
742
739
  method: 'PATCH',
743
740
  body: {
744
741
  title: nextTitle,
742
+ expectedTitleRevision: session.metadata?.openchamber?.titleAuthority?.sessionID === sessionId
743
+ ? session.metadata.openchamber.titleAuthority.revision : null,
745
744
  metadata: {
746
745
  openchamber: {
747
746
  titleRefresh: {
@@ -777,6 +776,16 @@ export const createSessionTitleRuntime = ({
777
776
  const processPayload = (payload, directoryHint = '') => {
778
777
  if (stopped) return;
779
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
+ }
780
789
  const createdSession = extractCreatedSession(payload);
781
790
  if (createdSession) {
782
791
  if (isForkedSessionTitle(createdSession.title)) {
@@ -848,6 +857,7 @@ export const createSessionTitleRuntime = ({
848
857
 
849
858
  const stop = () => {
850
859
  stopped = true;
860
+ sessionAccess?.stop?.();
851
861
  for (const { timer } of timers.values()) {
852
862
  clearTimeout(timer);
853
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'))) {
@@ -1,4 +1,4 @@
1
- import { beforeEach, expect, it, vi } from 'vitest';
1
+ import { afterEach, beforeEach, expect, it, vi } from 'vitest';
2
2
  import { mergeMetadataPatch } from '../session-metadata/session-metadata-store.js';
3
3
 
4
4
  const { api } = vi.hoisted(() => ({ api: {
@@ -8,17 +8,21 @@ const { api } = vi.hoisted(() => ({ api: {
8
8
  vi.mock('../opencode/v2-client.js', () => ({ makeOpenCodeV2Client: () => api }));
9
9
  import { createTitleSessionAccess } from './session-access.js';
10
10
 
11
- beforeEach(() => vi.resetAllMocks());
11
+ const accesses = [];
12
+ beforeEach(() => { vi.resetAllMocks(); vi.useFakeTimers(); });
13
+ afterEach(() => { for (const access of accesses.splice(0)) access.stop(); vi.useRealTimers(); });
12
14
 
13
15
  function fixture() {
14
16
  let metadata = { openchamber: {
15
17
  goal: { status: 'active' },
16
18
  titleRefresh: { isGenerating: true, requestedAt: 1000, lastError: 'old failure', failedAt: 1 },
17
19
  } };
18
- api.session.get.mockResolvedValue({
20
+ const session = {
19
21
  id: 'ses_title', title: 'Updated title', location: { directory: '/repo' },
20
22
  time: { created: 10, updated: 20 },
21
- });
23
+ };
24
+ api.session.get.mockImplementation(async () => structuredClone(session));
25
+ api.session.update.mockImplementation(async ({ title }) => { session.title = title; });
22
26
  const persist = vi.fn(async (_id, patch) => { metadata = mergeMetadataPatch(metadata, patch); });
23
27
  const publish = vi.fn();
24
28
  const access = createTitleSessionAccess({
@@ -26,9 +30,17 @@ function fixture() {
26
30
  getOpenCodeAuthHeaders: () => ({}),
27
31
  readSessionMetadata: async () => metadata,
28
32
  persistSessionMetadata: persist,
33
+ mutateSessionMetadata: async (id, decide) => {
34
+ const decision = decide(metadata);
35
+ if (!decision.ok) return { committed: false, metadata };
36
+ await persist(id, decision.patch);
37
+ return { committed: true, metadata };
38
+ },
39
+ listSessionMetadata: async () => ({ ses_title: metadata }),
29
40
  publishSession: publish,
30
41
  });
31
- return { access, persist, publish, metadata: () => metadata };
42
+ accesses.push(access);
43
+ return { access, persist, publish, session, metadata: () => metadata };
32
44
  }
33
45
 
34
46
  it('publishes the full authoritative title row and clears Host loading without overwriting unrelated state', async () => {
@@ -42,6 +54,7 @@ it('publishes the full authoritative title row and clears Host loading without o
42
54
  expect(api.session.update.mock.calls[0][0]).toEqual({ sessionID: 'ses_title', title: 'Updated title' });
43
55
  expect(f.metadata()).toEqual({ openchamber: {
44
56
  goal: { status: 'active' }, titleRefresh: { lastAutoTitle: 'Updated title' },
57
+ titleAuthority: { sessionID: 'ses_title', title: 'Updated title', revision: expect.any(String), source: 'summary' },
45
58
  } });
46
59
  expect(f.publish).toHaveBeenCalledWith(expect.objectContaining({
47
60
  id: 'ses_title', title: 'Updated title', directory: '/repo', metadata: f.metadata(),
@@ -72,14 +85,119 @@ it('publishes a full loading row before the new session exists in client catalog
72
85
  expect(api.session.update).not.toHaveBeenCalled();
73
86
  });
74
87
 
75
- it('does not publish success on a failed title write', async () => {
88
+ it('retains durable intent and retries a failed upstream title write without publishing success', async () => {
76
89
  const f = fixture();
77
90
  api.session.update.mockRejectedValue(new Error('offline'));
78
91
  await expect(f.access.update('ses_title', '/repo', {
79
92
  title: 'Failed', metadata: { openchamber: { titleRefresh: { lastAutoTitle: 'Failed' } } },
80
93
  })).rejects.toThrow('offline');
81
- expect(f.persist).not.toHaveBeenCalled();
94
+ expect(f.metadata().openchamber.titleAuthority.title).toBe('Failed');
82
95
  expect(f.publish).not.toHaveBeenCalled();
96
+ api.session.update.mockImplementation(async ({ title }) => { f.session.title = title; });
97
+ await vi.runAllTimersAsync();
98
+ expect(f.session.title).toBe('Failed');
99
+ });
100
+
101
+ it.each(['before', 'after', 'during'])('rectifies upstream title writes %s our write', async (timing) => {
102
+ const f = fixture();
103
+ if (timing === 'before') f.session.title = 'Native';
104
+ await f.access.update('ses_title', '/repo', { title: 'Ours' });
105
+ if (timing !== 'before') f.session.title = 'Native';
106
+ if (timing === 'during') {
107
+ api.session.update.mockImplementationOnce(async () => { f.session.title = 'Native again'; });
108
+ }
109
+ for (let i = 0; i < 100; i++) f.access.reconcile('ses_title');
110
+ await vi.runAllTimersAsync();
111
+ expect(f.session.title).toBe('Ours');
112
+ expect(api.session.update.mock.calls.length).toBeLessThanOrEqual(3);
113
+ const writes = api.session.update.mock.calls.length;
114
+ f.access.reconcile('ses_title');
115
+ await vi.runAllTimersAsync();
116
+ expect(api.session.update).toHaveBeenCalledTimes(writes);
117
+ });
118
+
119
+ it('rejects stale generation after a manual rename and repairs from the newest authority', async () => {
120
+ const f = fixture();
121
+ await f.access.update('ses_title', '/repo', { title: 'Summary' });
122
+ const previous = f.metadata().openchamber.titleAuthority.revision;
123
+ await f.persist('ses_title', { openchamber: { titleAuthority: {
124
+ sessionID: 'ses_title', title: 'Manual', revision: 'manual-1', source: 'manual',
125
+ } } });
126
+ await f.access.update('ses_title', '/repo', { title: 'Late summary', expectedTitleRevision: previous });
127
+ await vi.runAllTimersAsync();
128
+ expect(f.session.title).toBe('Manual');
129
+ expect(f.metadata().openchamber.titleAuthority.source).toBe('manual');
130
+ });
131
+
132
+ it('re-reads authority when manual rename lands during an upstream repair', async () => {
133
+ const f = fixture();
134
+ await f.access.update('ses_title', '/repo', { title: 'Summary' });
135
+ f.session.title = 'Native';
136
+ api.session.get.mockImplementationOnce(async () => {
137
+ await f.persist('ses_title', { openchamber: { titleAuthority: {
138
+ sessionID: 'ses_title', title: 'Manual', revision: 'manual-2', source: 'manual',
139
+ } } });
140
+ return structuredClone(f.session);
141
+ });
142
+ await vi.runAllTimersAsync();
143
+ expect(f.session.title).toBe('Manual');
144
+ expect(f.publish.mock.calls.at(-1)[0].title).toBe('Manual');
145
+ });
146
+
147
+ it('recovers missed rename events after reconnect without regenerating a title', async () => {
148
+ const f = fixture();
149
+ await f.persist('ses_title', { openchamber: { titleAuthority: {
150
+ sessionID: 'ses_title', title: 'Durable', revision: 'saved', source: 'summary',
151
+ } } });
152
+ await f.access.reconcileAll();
153
+ await vi.runAllTimersAsync();
154
+ expect(f.session.title).toBe('Durable');
155
+ });
156
+
157
+ it('does not adopt inherited fork authority or legacy lastAutoTitle as a lock', async () => {
158
+ const f = fixture();
159
+ await f.persist('ses_title', { openchamber: {
160
+ titleRefresh: { lastAutoTitle: 'Old summary' },
161
+ titleAuthority: { sessionID: 'ses_parent', title: 'Parent', revision: 'parent' },
162
+ } });
163
+ f.access.reconcile('ses_title');
164
+ await vi.runAllTimersAsync();
165
+ expect(api.session.update).not.toHaveBeenCalled();
166
+ });
167
+
168
+ it('does not write a title if durable authority persistence fails', async () => {
169
+ const f = fixture();
170
+ f.persist.mockRejectedValue(new Error('disk unavailable'));
171
+ await expect(f.access.update('ses_title', '/repo', { title: 'Unsaved' })).rejects.toThrow('disk unavailable');
172
+ await vi.runAllTimersAsync();
173
+ expect(api.session.update).not.toHaveBeenCalled();
174
+ expect(f.publish).not.toHaveBeenCalled();
175
+ });
176
+
177
+ it('stops repair after authoritative deletion', async () => {
178
+ const f = fixture();
179
+ await f.persist('ses_title', { openchamber: { titleAuthority: {
180
+ sessionID: 'ses_title', title: 'Durable', revision: 'saved',
181
+ } } });
182
+ api.session.get.mockRejectedValue(Object.assign(new Error('deleted'), { status: 404 }));
183
+ f.access.reconcile('ses_title');
184
+ await vi.runAllTimersAsync();
185
+ expect(api.session.get).toHaveBeenCalledTimes(1);
186
+ expect(api.session.update).not.toHaveBeenCalled();
187
+ });
188
+
189
+ it('backs off transient failures and cancels retry on stop', async () => {
190
+ const f = fixture();
191
+ await f.persist('ses_title', { openchamber: { titleAuthority: {
192
+ sessionID: 'ses_title', title: 'Durable', revision: 'saved',
193
+ } } });
194
+ api.session.get.mockRejectedValue(new Error('offline'));
195
+ f.access.reconcile('ses_title');
196
+ await vi.advanceTimersByTimeAsync(3000);
197
+ expect(api.session.get).toHaveBeenCalledTimes(3);
198
+ f.access.stop();
199
+ await vi.advanceTimersByTimeAsync(60_000);
200
+ expect(api.session.get).toHaveBeenCalledTimes(3);
83
201
  });
84
202
 
85
203
  it('does not turn a failed or malformed message read into empty success', async () => {
@@ -43,7 +43,8 @@ This module provides server-side Text-to-Speech services using OpenAI's TTS API.
43
43
  ## TTSService methods
44
44
 
45
45
  ### `isAvailable()`
46
- Returns boolean indicating whether OpenAI API key is configured (checks environment variable `OPENAI_API_KEY` or OpenCode auth file).
46
+ Returns a Promise of boolean after reading the current OpenCode selected credential.
47
+ Credential-source failure rejects; logout clears the previously constructed client.
47
48
 
48
49
  ### `generateSpeechStream(options)`
49
50
  Generates speech and returns as a web stream for direct streaming to clients.
@@ -81,10 +82,11 @@ Returns object with:
81
82
  Returns Buffer containing MP3 audio data.
82
83
 
83
84
  ## API key resolution
84
- OpenAI API keys are resolved in order:
85
- 1. Environment variable `OPENAI_API_KEY`.
86
- 2. OpenCode auth file (`auth.openai`, `auth.codex`, or `auth.chatgpt`).
87
- 3. Supports both string format (just token) and object format (with `access` or `token` fields).
85
+ Explicit request API key/custom base URL remains supported. Otherwise the host
86
+ reads the current OpenCode credential API (`openai`, `codex`, `chatgpt` aliases),
87
+ using API `key` or OAuth `access`. There is no host-local environment/file fallback
88
+ that could substitute another account for a remote service. A credential read
89
+ failure propagates; selected-account changes replace the client and logout clears it.
88
90
 
89
91
  ## Usage in web server
90
92
  The TTS module is used by `packages/web/server/index.js` for:
@@ -0,0 +1,23 @@
1
+ import { afterEach, expect, it } from 'vitest';
2
+ import { configureOpenCodeCredentials } from '../opencode/auth.js';
3
+ import { TTSService } from './service.js';
4
+
5
+ afterEach(() => configureOpenCodeCredentials(null));
6
+
7
+ it('TTS clears its old client after logout and obtains a new client after account switching', async () => {
8
+ let token = 'first';
9
+ configureOpenCodeCredentials(() => ({ credential: { list: async () => token ? [{ id: 'selected', integrationID: 'openai', active: true, value: { type: 'key', key: token } }] : [] } }));
10
+ const service = new TTSService();
11
+ const first = await service._getClient();
12
+ expect(await service.isAvailable()).toBe(true);
13
+ token = 'second';
14
+ expect(await service._getClient()).not.toBe(first);
15
+ token = '';
16
+ expect(await service.isAvailable()).toBe(false);
17
+ expect(await service._getClient()).toBeNull();
18
+ });
19
+
20
+ it('TTS does not present credential read failure as unavailable success', async () => {
21
+ configureOpenCodeCredentials(() => ({ credential: { list: async () => { throw new Error('private'); } } }));
22
+ await expect(new TTSService().isAvailable()).rejects.toThrow(/^Unable to read OpenCode credentials$/);
23
+ });
@@ -62,7 +62,7 @@ export function registerTtsRoutes(app, { sayTTSCapability }) {
62
62
  const { ttsService } = await getTtsModule();
63
63
 
64
64
  // Check availability - server-configured key, client-provided key, or custom server URL
65
- const hasServerKey = ttsService.isAvailable();
65
+ const hasServerKey = await ttsService.isAvailable();
66
66
  const hasClientKey = apiKey && typeof apiKey === 'string' && apiKey.trim().length > 0;
67
67
  const hasCustomBaseURL = typeof normalizedBaseURL === 'string' && normalizedBaseURL.length > 0;
68
68
 
@@ -134,7 +134,7 @@ export function registerTtsRoutes(app, { sayTTSCapability }) {
134
134
  try {
135
135
  const { ttsService } = await getTtsModule();
136
136
  res.json({
137
- available: ttsService.isAvailable(),
137
+ available: await ttsService.isAvailable(),
138
138
  voices: [
139
139
  'alloy', 'ash', 'ballad', 'coral', 'echo', 'fable',
140
140
  'nova', 'onyx', 'sage', 'shimmer', 'verse', 'marin', 'cedar'