@vantran-se/openchamber-web 2.0.4 → 2.0.6

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 (243) hide show
  1. package/dist/assets/{CodeMirrorCommentWidgets-DZF8ntaq.js → CodeMirrorCommentWidgets-BlzB2e0C.js} +1 -1
  2. package/dist/assets/DiffView-D7JQzQVZ.js +4 -0
  3. package/dist/assets/FilesView-DC2gadXM.js +17 -0
  4. package/dist/assets/{GitView-DwQwqDvu.js → GitView-Gzl0wbdd.js} +4 -4
  5. package/dist/assets/{GuestAttachDialog-CoXM0gh_.js → GuestAttachDialog-CQdDIeco.js} +1 -1
  6. package/dist/assets/GuestSurfacePane-CrOhlIdt.js +1 -0
  7. package/dist/assets/{JsonTreeView-Cq_27v0y.js → JsonTreeView-C3QZifYe.js} +1 -1
  8. package/dist/assets/{LinearIssuesView-CsSSRMhn.js → LinearIssuesView-X27TCI3v.js} +1 -1
  9. package/dist/assets/MarkdownImageGallery-BlTWU9oH.js +1 -0
  10. package/dist/assets/MarkdownRendererImpl-Cno7QsJO.js +12 -0
  11. package/dist/assets/{OnboardingScreen-ClatMQAX.js → OnboardingScreen-Wr_UzNtt.js} +1 -1
  12. package/dist/assets/{PatchDiff-DOwlrIK9.js → PatchDiff-D6pWS7cD.js} +1 -1
  13. package/dist/assets/{PierreDiffViewer-CrcGqqD_.js → PierreDiffViewer-DDljFbJJ.js} +3 -3
  14. package/dist/assets/{PlanView-BGLsMgQk.js → PlanView-LcfEbBPi.js} +4 -4
  15. package/dist/assets/PluginPane-DPWPLc2f.js +3 -0
  16. package/dist/assets/SettingsView-CFe9O3EM.js +30 -0
  17. package/dist/assets/{SettingsWindow-B0BAy-f9.js → SettingsWindow-DT1xhvgi.js} +1 -1
  18. package/dist/assets/{SubmoduleDiffSummary-fcdDzCSW.js → SubmoduleDiffSummary-CBHXaIkB.js} +1 -1
  19. package/dist/assets/{ToolOutputDialog-B7xaUz2H.js → ToolOutputDialog-B_ZX8ZbZ.js} +6 -6
  20. package/dist/assets/{ToolPartDiffPreview-CkWCaUmZ.js → ToolPartDiffPreview-D_HQzkXU.js} +2 -2
  21. package/dist/assets/{VirtualizedFileDiff-DONC4eI2.js → VirtualizedFileDiff-BUK1ujXg.js} +1 -1
  22. package/dist/assets/{WalkthroughView-CXDImOXW.js → WalkthroughView-H-tYOvhO.js} +2 -2
  23. package/dist/assets/{appThemeRegistry-Br2TLSlb.js → appThemeRegistry-BoJGAwCP.js} +1 -1
  24. package/dist/assets/changesTree-CePHbFcj.js +1 -0
  25. package/dist/assets/commitAndPush-BzZrF8bK.js +1 -0
  26. package/dist/assets/{computeVirtualFileMetrics-_-20umCs.js → computeVirtualFileMetrics-kYyVjXLu.js} +2 -2
  27. package/dist/assets/de-ByfQDcPj.js +10 -0
  28. package/dist/assets/es-DzNbjsQS.js +16 -0
  29. package/dist/assets/fr-B8AVLrnX.js +14 -0
  30. package/dist/assets/{index-OKDYjcMF.js → index-0-ON1L7L.js} +1 -1
  31. package/dist/assets/index-DUke1nme.css +1 -0
  32. package/dist/assets/index-Di6zUe5h.js +41 -0
  33. package/dist/assets/{isDiffPlainText-D77M6q1f.js → isDiffPlainText-Bgm_aLpu.js} +1 -1
  34. package/dist/assets/{isFilePlainText-C54B4Mns.js → isFilePlainText-D5GMgURH.js} +1 -1
  35. package/dist/assets/ja-1g6S0G19.js +16 -0
  36. package/dist/assets/ko-CxplvYkJ.js +16 -0
  37. package/dist/assets/{main-CGEvLMd2.js → main--Y98ZLsD.js} +2 -2
  38. package/dist/assets/main-GjXmVNpD.js +896 -0
  39. package/dist/assets/{markdown-shiki.worker-CahLvHIq.js → markdown-shiki.worker-Bg8SoAJJ.js} +8 -5
  40. package/dist/assets/markdownCore-CVdUcVCi.js +7 -0
  41. package/dist/assets/{miniChat-BSxkRQLj.js → miniChat-tTx5mpbd.js} +2 -2
  42. package/dist/assets/{mobile-C5KRTNOB.js → mobile-BmbiDWGb.js} +2 -2
  43. package/dist/assets/patchFileDiff-CV4iVQ8L.js +13 -0
  44. package/dist/assets/pl-f3QyqvIB.js +16 -0
  45. package/dist/assets/pt-BR-BsaoaiCK.js +16 -0
  46. package/dist/assets/{renderElectronMiniChatApp-CnkAQV6o.js → renderElectronMiniChatApp-BnCdnDhv.js} +2 -2
  47. package/dist/assets/renderMobileApp-CKn8nbSW.js +25 -0
  48. package/dist/assets/runtimeEndpointReset-sCi88eed.js +27 -0
  49. package/dist/assets/scheduledTasksApi-Dahv3ws0.js +1 -0
  50. package/dist/assets/{shikiHighlight-4O0ZM14D.js → shikiHighlight-O5_xLmWt.js} +1 -1
  51. package/dist/assets/{textMateThemeFromAppTheme-yNq7b0DZ.js → textMateThemeFromAppTheme-XM4VefqN.js} +1 -1
  52. package/dist/assets/tr-BNFsa6nz.js +7 -0
  53. package/dist/assets/uk-CJ9y5ULA.js +16 -0
  54. package/dist/assets/{useAppFontEffects-Bzyszv2N.js → useAppFontEffects-BN06mgHB.js} +160 -156
  55. package/dist/assets/{useInlineCommentController-zOf6ORAq.js → useInlineCommentController-Bb9Gjz1Q.js} +2 -2
  56. package/dist/assets/{useRootScrollLock-DCaZ4BWi.js → useRootScrollLock-Chk1cFUw.js} +2 -2
  57. package/dist/assets/{vendor-codemirror-language-data-6cEFPNUR.js → vendor-codemirror-language-data-C2qMZiA7.js} +2 -2
  58. package/dist/assets/vendor-opencode-client-DSZl7jSi.js +12 -0
  59. package/dist/assets/vendor-zod-BSoePkpP.js +39 -0
  60. package/dist/assets/{web-components-BEo4-ISu.js → web-components-BQWjCn_U.js} +1 -1
  61. package/dist/assets/zh-CN-U0QjVlAA.js +16 -0
  62. package/dist/assets/zh-TW-J9PTJQJi.js +16 -0
  63. package/dist/index.html +5 -5
  64. package/dist/mini-chat.html +5 -5
  65. package/dist/mobile.html +5 -5
  66. package/package.json +4 -4
  67. package/server/index.js +82 -0
  68. package/server/lib/agent-tool/DOCUMENTATION.md +132 -47
  69. package/server/lib/agent-tool/runtime.js +34 -15
  70. package/server/lib/agent-tool/runtime.test.js +15 -0
  71. package/server/lib/browser-control/DOCUMENTATION.md +14 -2
  72. package/server/lib/event-stream/global-hub.js +23 -1
  73. package/server/lib/event-stream/global-hub.test.js +35 -0
  74. package/server/lib/event-stream/global-ws-bridge.js +3 -1
  75. package/server/lib/event-stream/upstream-reader.js +3 -2
  76. package/server/lib/git/DOCUMENTATION.md +1 -1
  77. package/server/lib/git/service.js +22 -0
  78. package/server/lib/git/service.test.js +8 -0
  79. package/server/lib/guests/DOCUMENTATION.md +4 -3
  80. package/server/lib/guests/catalog.js +19 -1
  81. package/server/lib/guests/catalog.test.js +22 -0
  82. package/server/lib/guests/html-styles.js +5 -3
  83. package/server/lib/guests/html-styles.test.js +3 -1
  84. package/server/lib/guests/routes.js +11 -3
  85. package/server/lib/guests/service.js +5 -0
  86. package/server/lib/guests/surface.js +55 -9
  87. package/server/lib/guests/surface.test.js +56 -6
  88. package/server/lib/notifications/DOCUMENTATION.md +10 -0
  89. package/server/lib/notifications/emit-route.js +118 -0
  90. package/server/lib/notifications/emit-route.test.js +112 -0
  91. package/server/lib/openchamber-control/actions.js +16 -2
  92. package/server/lib/openchamber-control/service.js +23 -0
  93. package/server/lib/openchamber-control/service.test.js +31 -0
  94. package/server/lib/openchamber-sessions/fork-inheritance.js +103 -0
  95. package/server/lib/openchamber-sessions/fork-inheritance.test.js +69 -0
  96. package/server/lib/openchamber-sessions/routes.js +11 -0
  97. package/server/lib/openchamber-sessions/routes.test.js +18 -0
  98. package/server/lib/opencode/bootstrap-runtime.js +14 -1
  99. package/server/lib/opencode/compatibility.js +6 -3
  100. package/server/lib/opencode/compatibility.test.js +8 -0
  101. package/server/lib/opencode/config-entity-v2.test.js +27 -0
  102. package/server/lib/opencode/config-paths.test.js +12 -0
  103. package/server/lib/opencode/config-v2.d.ts +2 -0
  104. package/server/lib/opencode/config-v2.js +42 -0
  105. package/server/lib/opencode/config-v2.test.js +17 -0
  106. package/server/lib/opencode/core-routes.js +20 -4
  107. package/server/lib/opencode/core-routes.test.js +41 -0
  108. package/server/lib/opencode/env-runtime.js +1 -8
  109. package/server/lib/opencode/feature-routes-runtime.js +2 -1
  110. package/server/lib/opencode/lifecycle.js +30 -2
  111. package/server/lib/opencode/lifecycle.test.js +27 -0
  112. package/server/lib/opencode/managed-config-file.js +18 -6
  113. package/server/lib/opencode/managed-config-file.test.js +23 -7
  114. package/server/lib/opencode/mcp.js +7 -2
  115. package/server/lib/opencode/npm-registry-config.d.ts +1 -0
  116. package/server/lib/opencode/npm-registry-config.js +123 -0
  117. package/server/lib/opencode/npm-registry-config.test.js +167 -0
  118. package/server/lib/opencode/npm-registry.js +5 -6
  119. package/server/lib/opencode/npm-registry.test.js +30 -0
  120. package/server/lib/opencode/openchamber-routes.js +29 -11
  121. package/server/lib/opencode/openchamber-routes.test.js +18 -0
  122. package/server/lib/opencode/project-directory-runtime.js +8 -0
  123. package/server/lib/opencode/project-directory-runtime.test.js +13 -0
  124. package/server/lib/opencode/provider-source-route.test.js +59 -0
  125. package/server/lib/opencode/providers.js +3 -0
  126. package/server/lib/opencode/providers.test.js +62 -0
  127. package/server/lib/opencode/proxy.js +38 -2
  128. package/server/lib/opencode/routes-upgrade.test.js +1 -1
  129. package/server/lib/opencode/routes.js +36 -1
  130. package/server/lib/opencode/server-utils-runtime.js +5 -0
  131. package/server/lib/opencode/settings-helpers.js +10 -0
  132. package/server/lib/opencode/settings-helpers.test.js +2 -2
  133. package/server/lib/opencode/settings-registry.json +17 -0
  134. package/server/lib/opencode/settings-runtime.js +1 -0
  135. package/server/lib/opencode/shared.js +49 -4
  136. package/server/lib/opencode/shared.test.js +78 -1
  137. package/server/lib/opencode/shutdown-runtime.js +3 -0
  138. package/server/lib/opencode/skill-routes.js +28 -12
  139. package/server/lib/opencode/skill-routes.test.js +121 -1
  140. package/server/lib/opencode/upgrade-capability.js +0 -14
  141. package/server/lib/opencode/v1-migration-topup.js +21 -15
  142. package/server/lib/opencode/v1-migration-topup.test.js +14 -6
  143. package/server/lib/opencode/v2-install.d.ts +1 -0
  144. package/server/lib/opencode/v2-install.js +8 -7
  145. package/server/lib/opencode/v2-install.test.js +13 -4
  146. package/server/lib/opencode/watcher.js +3 -1
  147. package/server/lib/opencode/websearch-config.js +13 -1
  148. package/server/lib/quota/providers/kimi.js +1 -1
  149. package/server/lib/realtime-proxy.js +5 -1
  150. package/server/lib/realtime-proxy.test.js +29 -0
  151. package/server/lib/relay/tunnel-host.js +4 -1
  152. package/server/lib/relay/tunnel-host.test.js +11 -0
  153. package/server/lib/session-assist/DOCUMENTATION.md +17 -5
  154. package/server/lib/session-assist/runtime.js +19 -2
  155. package/server/lib/session-assist/runtime.test.js +8 -0
  156. package/server/lib/session-goal/DOCUMENTATION.md +11 -0
  157. package/server/lib/small-model/DOCUMENTATION.md +11 -10
  158. package/server/lib/small-model/index.js +27 -32
  159. package/server/lib/small-model/index.test.js +28 -16
  160. package/server/lib/spaces/DOCUMENTATION.md +404 -32
  161. package/server/lib/spaces/code-in-bait.js +145 -6
  162. package/server/lib/spaces/code-in.js +10 -150
  163. package/server/lib/spaces/code-in.test.js +7 -83
  164. package/server/lib/spaces/code-out.js +1819 -0
  165. package/server/lib/spaces/code-out.test.js +3945 -0
  166. package/server/lib/spaces/code-transfer.js +173 -0
  167. package/server/lib/spaces/command-stream.test.js +181 -0
  168. package/server/lib/spaces/dispatcher.js +670 -0
  169. package/server/lib/spaces/dispatcher.test.js +582 -0
  170. package/server/lib/spaces/gatekeeper-channel.js +61 -7
  171. package/server/lib/spaces/gatekeeper-channel.test.js +43 -7
  172. package/server/lib/spaces/gatekeeper-program.cjs +68 -14
  173. package/server/lib/spaces/gatekeeper-program.test.js +118 -3
  174. package/server/lib/spaces/hardening.js +20 -4
  175. package/server/lib/spaces/hardening.test.js +18 -3
  176. package/server/lib/spaces/host-git.js +7 -4
  177. package/server/lib/spaces/host-git.test.js +6 -2
  178. package/server/lib/spaces/host.js +310 -0
  179. package/server/lib/spaces/host.test.js +273 -0
  180. package/server/lib/spaces/journey.js +552 -0
  181. package/server/lib/spaces/journey.test.js +589 -0
  182. package/server/lib/spaces/layout.js +42 -8
  183. package/server/lib/spaces/manager.js +4 -3
  184. package/server/lib/spaces/manager.test.js +6 -0
  185. package/server/lib/spaces/places/code-out.docker.live.test.js +412 -0
  186. package/server/lib/spaces/places/contract-suite.js +36 -0
  187. package/server/lib/spaces/places/dispatcher.docker.live.test.js +296 -0
  188. package/server/lib/spaces/places/docker-live-support.js +27 -4
  189. package/server/lib/spaces/places/docker.js +33 -5
  190. package/server/lib/spaces/places/docker.test.js +42 -3
  191. package/server/lib/spaces/places/escape-suite.js +118 -14
  192. package/server/lib/spaces/places/fake-docker.js +3 -1
  193. package/server/lib/spaces/places/grants.docker.live.test.js +197 -0
  194. package/server/lib/spaces/places/memory-place.js +24 -1
  195. package/server/lib/spaces/places/registry.js +1 -1
  196. package/server/lib/spaces/places/server.docker.live.test.js +69 -33
  197. package/server/lib/spaces/places/sessions.docker.live.test.js +222 -0
  198. package/server/lib/spaces/routes.js +216 -0
  199. package/server/lib/spaces/routes.test.js +213 -0
  200. package/server/lib/spaces/run-command.js +261 -8
  201. package/server/lib/spaces/run-command.test.js +193 -3
  202. package/server/lib/spaces/space-events.js +153 -0
  203. package/server/lib/spaces/space-events.test.js +160 -0
  204. package/server/lib/spaces/space-opencode.js +52 -0
  205. package/server/lib/spaces/space-opencode.test.js +41 -0
  206. package/server/lib/spaces/space-records.js +150 -0
  207. package/server/lib/spaces/space-records.test.js +113 -0
  208. package/server/lib/spaces/space-sessions.js +210 -0
  209. package/server/lib/spaces/space-sessions.test.js +161 -0
  210. package/server/lib/spaces/websocket.js +329 -0
  211. package/server/lib/spaces/websocket.test.js +345 -0
  212. package/server/lib/ui-auth/ui-auth.js +8 -2
  213. package/server/lib/ui-auth/ui-auth.test.js +29 -0
  214. package/server/opencode-proxy.test.js +113 -0
  215. package/dist/assets/DiffView-D2p4Ar0H.js +0 -4
  216. package/dist/assets/FilesView-BeTtbsip.js +0 -17
  217. package/dist/assets/GuestSurfacePane-DIJvkkM-.js +0 -1
  218. package/dist/assets/MarkdownImageGallery-CY0dWTaE.js +0 -1
  219. package/dist/assets/MarkdownRendererImpl-ILdXzLrt.js +0 -12
  220. package/dist/assets/PluginPane-B39JEUL8.js +0 -3
  221. package/dist/assets/SettingsView-13B2_G-A.js +0 -29
  222. package/dist/assets/commitAndPush-CcI4ol7c.js +0 -1
  223. package/dist/assets/de-BtYnUgII.js +0 -10
  224. package/dist/assets/es-crP5qPye.js +0 -16
  225. package/dist/assets/fr-CLSRuALk.js +0 -14
  226. package/dist/assets/index-CLcM8HKx.css +0 -1
  227. package/dist/assets/index-CvLgnuNI.js +0 -41
  228. package/dist/assets/ja-SZ1-ucLV.js +0 -16
  229. package/dist/assets/ko-HthbT_Ve.js +0 -16
  230. package/dist/assets/main-BJklC7Cl.js +0 -896
  231. package/dist/assets/markdownCore-BzQuETm0.js +0 -5
  232. package/dist/assets/patchFileDiff-DGw2KryM.js +0 -13
  233. package/dist/assets/pl-C8eYfjL1.js +0 -16
  234. package/dist/assets/pt-BR-cYOrZdxS.js +0 -16
  235. package/dist/assets/renderMobileApp-CMxjwisj.js +0 -25
  236. package/dist/assets/runtimeEndpointReset-C45jrFq4.js +0 -27
  237. package/dist/assets/scheduledTasksApi-CkK1F9s2.js +0 -1
  238. package/dist/assets/tr-B4IQ5t-T.js +0 -7
  239. package/dist/assets/uk-DCoXG6ve.js +0 -16
  240. package/dist/assets/vendor-opencode-client-_BAHQo4R.js +0 -12
  241. package/dist/assets/vendor-zod-Gea9uBlY.js +0 -39
  242. package/dist/assets/zh-CN-BXBoxsFk.js +0 -16
  243. package/dist/assets/zh-TW-C6oelpnL.js +0 -16
@@ -121,13 +121,25 @@ before work and before writing. With both off there are no reads, model calls,
121
121
  or writes. With one on, the shared recent context is still available, but only
122
122
  that field is requested. An empty suggestion does not erase a valid recap.
123
123
 
124
- Clients render an assist only while its `forMessageID` is the last message and
125
- the session is idle. A new message invalidates it without clearing writes.
126
-
127
- - `packages/ui/src/lib/sessionAssistMetadata.ts` parses the payload.
128
- - `packages/ui/src/hooks/useSessionAssist.ts` owns freshness/settings gating.
124
+ Freshness has one rule, `getCurrentSessionAssist` in
125
+ `packages/ui/src/lib/sessionAssistMetadata.ts`, computed from the session
126
+ record alone so the chat and the sidebar row always agree: the payload is
127
+ current while `generatedAt >= session.time.idle` and the session is not
128
+ reverted. OpenCode moves `time.idle` at every turn end, succeeded or failed.
129
+ Do not compare `forMessageID` with the last loaded message: in v2 the newest
130
+ record is the turn's `idle` marker or a switch record, never the answer.
131
+ When a session turns busy, the runtime also deletes the assist it wrote
132
+ (`persistSessionAssist(id, dir, null)`), so stored state goes stale only for
133
+ payloads written by an earlier process; the `time.idle` rule retires those.
134
+
135
+ - `packages/ui/src/lib/sessionAssistMetadata.ts` parses the payload and owns freshness.
136
+ - `packages/ui/src/hooks/useSessionAssist.ts` adds live-status and settings gating.
129
137
  - `SessionRecapSpacer` shows the reminder in the reserved gap under the reply.
130
138
  - `SessionSuggestionChip` fills the composer; it never sends automatically.
139
+ - Sidebar rows (`SessionNodeItem`, both Projects and Timeline) mark a session
140
+ whose suggestion is still open with a small icon and the suggestion as its
141
+ title, using the same freshness rule (`getOpenSessionSuggestion`). The marker hides while a turn runs, on the open session, and
142
+ when `sessionSuggestionEnabled` is off.
131
143
 
132
144
  Web, Electron, hosted mobile, and Capacitor use the server watcher. VS Code's
133
145
  extension-only runtime does not generate assists; shared UI can render payloads
@@ -1,5 +1,6 @@
1
1
  // Background session assistance. Only live idle events arm generation; there
2
- // is no backfill. Clients hide results whose forMessageID is no longer current.
2
+ // is no backfill. A new turn deletes the assist this process wrote; clients
3
+ // retire any other payload older than the session's `time.idle`.
3
4
  import fs from 'fs';
4
5
  import os from 'os';
5
6
  import path from 'path';
@@ -99,6 +100,8 @@ export const createSessionAssistRuntime = ({
99
100
  const timers = new Map();
100
101
  const inflight = new Map();
101
102
  const ready = new Map();
103
+ // Sessions holding an assist this process wrote, keyed to their directory.
104
+ const persisted = new Map();
102
105
  let stopped = false;
103
106
 
104
107
  const clearTimer = (sessionId) => {
@@ -115,6 +118,16 @@ export const createSessionAssistRuntime = ({
115
118
  inflight.get(sessionId)?.controller.abort();
116
119
  };
117
120
 
121
+ // A new turn makes the stored recap and suggestion describe an older turn:
122
+ // delete them so "has a suggestion" in metadata means the same everywhere.
123
+ const retireStored = (sessionId, directory) => {
124
+ if (!persisted.has(sessionId)) return;
125
+ const storedDirectory = persisted.get(sessionId);
126
+ persisted.delete(sessionId);
127
+ Promise.resolve(persistSessionAssist(sessionId, directory || storedDirectory, null))
128
+ .catch(() => console.warn('[session-assist] failed to retire a stale assist'));
129
+ };
130
+
118
131
  const generateAssist = async (sessionId, directory, signal) => {
119
132
  const targets = getTargets();
120
133
  if (!targets.recap && !targets.suggestion) return;
@@ -206,6 +219,7 @@ export const createSessionAssistRuntime = ({
206
219
  forMessageID: last.id,
207
220
  generatedAt: Date.now(),
208
221
  });
222
+ persisted.set(sessionId, directory);
209
223
  };
210
224
 
211
225
  const startGeneration = (sessionId, directory, armedAt) => {
@@ -254,7 +268,10 @@ export const createSessionAssistRuntime = ({
254
268
  const status = extractSessionStatus(payload);
255
269
  if (status) {
256
270
  if (status.type === 'idle') armTimer(status.sessionId, status.directory || directoryHint);
257
- else invalidate(status.sessionId);
271
+ else {
272
+ invalidate(status.sessionId);
273
+ retireStored(status.sessionId, status.directory || directoryHint);
274
+ }
258
275
  return;
259
276
  }
260
277
  const userMessage = extractUserMessage(payload);
@@ -153,6 +153,14 @@ describe('session assist runtime', () => {
153
153
  expect(persistSessionAssist.mock.calls[0][2]).toMatchObject({ recap: 'Did the thing.', suggestion: 'Verify it.', forMessageID: 'msg_a' });
154
154
  // The pre-write re-check must see past the idle marker as well.
155
155
  expect(listLimits.at(-1)).toBeGreaterThan(2);
156
+
157
+ // A new turn deletes the stored assist, once.
158
+ const busy = { type: 'session.status', properties: { sessionID: 'ses_1', status: { type: 'busy' } } };
159
+ runtime.processPayload(busy);
160
+ runtime.processPayload(busy);
161
+ await new Promise((resolve) => setTimeout(resolve, 0));
162
+ expect(persistSessionAssist).toHaveBeenCalledTimes(2);
163
+ expect(persistSessionAssist.mock.calls[1][2]).toBeNull();
156
164
  vi.unstubAllGlobals();
157
165
  });
158
166
 
@@ -70,6 +70,17 @@ before touching the filesystem). Rationale: metadata rides every
70
70
  objective fitting, inline fallback, metadata creation, and the synthetic
71
71
  first-turn reminder shared by scheduled tasks and CLI-created sessions.
72
72
 
73
+ - Forks: OpenCode copies the source metadata into a fork, goal included.
74
+ The UI fork paths (`forkAfterMessage`, `forkFromMessage`, `/fork`) then copy
75
+ the objective file to the fork's id, or inline the text when that write
76
+ fails (`packages/ui/src/lib/sessionForkInheritance.ts`). The copy re-reads
77
+ the fork's goal id first and is skipped when the user armed a new goal on
78
+ the fork meanwhile. Forks made through `POST /api/openchamber/sessions/:id/fork`
79
+ (CLI, scheduled tasks) get the same repair server-side before the prompt is
80
+ dispatched (`openchamber-sessions/fork-inheritance.js`); UI forks call
81
+ OpenCode directly, so both implementations exist. `/btw` forks drop the goal
82
+ with the rest of the inherited namespace.
83
+
73
84
  ## Flow
74
85
 
75
86
  1. `createSessionGoalRuntime` subscribes to the global SSE hub (same pattern
@@ -81,12 +81,10 @@ Four things are decided here, in order:
81
81
  5. Otherwise `GET /api/model/default` — `source: 'default'`. This is
82
82
  OpenCode's default chat model, not a small one; it is the last resort.
83
83
 
84
- Claude Code is refused unconditionally (`422`,
85
- `code: 'small-model-provider-unsupported'`). A plugin can publish an
86
- OpenAI-compatible endpoint for it, but that endpoint is a façade over the
87
- Claude Agent SDK, which spawns the Claude Code CLI per request and spends the
88
- user's Claude subscription rate limit. An available endpoint does not lift the
89
- refusal — the cost is the reason, not the transport.
84
+ Claude Code (`claude-code`, from the opencode-claude plugin) is a provider
85
+ like any other: its generate path runs a clean one-shot turn with no tools and
86
+ no session, so `claude-code/haiku` (family `claude-haiku`) is picked in the
87
+ normal family order.
90
88
 
91
89
  ## Prompt shape
92
90
 
@@ -120,10 +118,13 @@ model they got. The resolved value comes back as `outputTokens`.
120
118
  longer wanted. The timeout covers generation, retry waits and structured-output
121
119
  retries together.
122
120
 
123
- OpenCode 2 can reject an explicit model before its cold catalog finishes loading.
124
- An `InvalidRequestError` whose message exactly names the selected model as
125
- `Model unavailable: provider/model` gets one retry after 500 ms, with the same
126
- model and prompt. Cancellation also stops the wait. Other errors are not retried.
121
+ OpenCode 2 can reject an explicit model before its cold catalog finishes loading;
122
+ plugin-provided models (Claude Code) stay unavailable for 20-40 s after OpenCode
123
+ starts because plugins for the global location load lazily. An
124
+ `InvalidRequestError` whose message exactly names the selected model as
125
+ `Model unavailable: provider/model` is retried with exponential backoff (0.5, 1,
126
+ 2, 4, 8, 16 s, ~31 s in total), with the same model and prompt. The rejection
127
+ precedes provider dispatch, so retries cost no tokens. Cancellation also stops the wait. Other errors are not retried.
127
128
  If the model remains unavailable, the route returns 503 with
128
129
  `code: 'small-model-unavailable'` and the model-specific reason. Commit and PR
129
130
  generation own their error toast and suppress the shared request toast.
@@ -11,16 +11,20 @@ import {
11
11
  listProviderInfos,
12
12
  } from './client.js';
13
13
 
14
- // Never a small model, whatever the transport looks like. A plugin can publish
15
- // an OpenAI-compatible endpoint for Claude Code, but it is a façade over the
16
- // Claude Agent SDK, which spawns the Claude Code CLI per request and spends
17
- // the user's Claude subscription rate limit. Paying that for a session title
18
- // or a summary is the wrong trade, so the refusal is unconditional rather than
19
- // conditional on an endpoint existing.
20
- const CLAUDE_CODE_PROVIDER = 'claude-code';
21
-
22
14
  const DEFAULT_TIMEOUT_MS = 60_000;
23
15
 
16
+ // Waits between retries of `Model unavailable`, ~31 s in total. Right after
17
+ // OpenCode starts, plugin-provided models (claude-code) stay unavailable for
18
+ // 20-40 s while plugins for the global location load lazily. The rejection
19
+ // precedes provider dispatch, so a retry costs no tokens.
20
+ const UNAVAILABLE_RETRY_DELAYS_MS = [500, 1_000, 2_000, 4_000, 8_000, 16_000];
21
+ let unavailableRetryDelaysMs = UNAVAILABLE_RETRY_DELAYS_MS;
22
+
23
+ /** Test hook: replace the backoff schedule; no argument restores the default. */
24
+ export const setUnavailableRetryDelaysForTest = (delays = UNAVAILABLE_RETRY_DELAYS_MS) => {
25
+ unavailableRetryDelaysMs = delays;
26
+ };
27
+
24
28
  const OPENCHAMBER_SETTINGS_FILE = path.join(
25
29
  process.env.OPENCHAMBER_DATA_DIR
26
30
  ? path.resolve(process.env.OPENCHAMBER_DATA_DIR)
@@ -151,7 +155,6 @@ export const pickSmallModelAnywhere = (models) => pickSmallModel(models, () => t
151
155
  const pickSmallModel = (models, accept) => {
152
156
  const candidates = models
153
157
  .filter((model) => model && accept(model)
154
- && model.providerID !== CLAUDE_CODE_PROVIDER
155
158
  && model.enabled !== false
156
159
  && (model.status === undefined || model.status === 'active')
157
160
  && (model.capabilities?.input ?? ['text']).some((item) => String(item).startsWith('text'))
@@ -260,13 +263,6 @@ export async function generateSmallModelText({ prompt, system, maxOutputTokens,
260
263
  );
261
264
  }
262
265
 
263
- if (resolved.providerID === CLAUDE_CODE_PROVIDER) {
264
- throw Object.assign(
265
- new Error('Claude Code cannot be used for background small-model actions. Choose another Small Model in Settings → Sessions.'),
266
- { statusCode: 422, code: 'small-model-provider-unsupported' },
267
- );
268
- }
269
-
270
266
  // A caller that must stay on its session's provider is only overruled by an
271
267
  // explicit user choice (the settings override or a request model).
272
268
  if (restrictToPreferredProvider
@@ -302,7 +298,6 @@ export async function generateSmallModelText({ prompt, system, maxOutputTokens,
302
298
 
303
299
  const generationOptions = requestOptions({ timeoutMs, signal });
304
300
  const unavailableMessage = `Model unavailable: ${resolved.providerID}/${resolved.modelID}`;
305
- let retriedUnavailable = false;
306
301
  const send = async () => {
307
302
  const result = await client.generate.text(
308
303
  { prompt: fullPrompt, model: { id: resolved.modelID, providerID: resolved.providerID } },
@@ -312,21 +307,22 @@ export async function generateSmallModelText({ prompt, system, maxOutputTokens,
312
307
  };
313
308
 
314
309
  const sendWithCatalogRetry = async () => {
315
- try {
316
- return await send();
317
- } catch (error) {
318
- if (error?._tag !== 'InvalidRequestError' || error.message !== unavailableMessage) throw error;
319
- // OpenCode 2 can resolve a cold catalog before its models arrive.
320
- // This rejection precedes provider dispatch; other failures must not retry.
321
- if (!retriedUnavailable) {
322
- retriedUnavailable = true;
323
- await delay(500, undefined, { signal: generationOptions.signal });
324
- return sendWithCatalogRetry();
310
+ for (let attempt = 0; ; attempt += 1) {
311
+ try {
312
+ return await send();
313
+ } catch (error) {
314
+ if (error?._tag !== 'InvalidRequestError' || error.message !== unavailableMessage) throw error;
315
+ // OpenCode 2 can resolve a cold catalog before its models arrive.
316
+ // This rejection precedes provider dispatch; other failures must not retry.
317
+ if (attempt < unavailableRetryDelaysMs.length) {
318
+ await delay(unavailableRetryDelaysMs[attempt], undefined, { signal: generationOptions.signal });
319
+ continue;
320
+ }
321
+ throw Object.assign(new Error(unavailableMessage), {
322
+ statusCode: 503,
323
+ code: 'small-model-unavailable',
324
+ });
325
325
  }
326
- throw Object.assign(new Error(unavailableMessage), {
327
- statusCode: 503,
328
- code: 'small-model-unavailable',
329
- });
330
326
  }
331
327
  };
332
328
 
@@ -386,7 +382,6 @@ export async function listAuthenticatedProviders() {
386
382
  if (typeof provider?.id === 'string' && enabled.has(provider.id)) ids.add(provider.id);
387
383
  }
388
384
  for (const id of enabled) ids.add(id);
389
- ids.delete(CLAUDE_CODE_PROVIDER);
390
385
  return Array.from(ids);
391
386
  } catch {
392
387
  return [];
@@ -3,14 +3,14 @@ import { registerSmallModelRoutes } from './routes.js';
3
3
  import http from 'node:http';
4
4
  import os from 'os';
5
5
  import path from 'path';
6
- import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'bun:test';
6
+ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'bun:test';
7
7
 
8
8
  // The settings override is read straight from disk, so without this the suite
9
9
  // would resolve whatever small model the developer running it has configured.
10
10
  const TEMP_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'small-model-settings-'));
11
11
  process.env.OPENCHAMBER_DATA_DIR = TEMP_DATA_DIR;
12
12
 
13
- const { generateSmallModelText, describeSmallModel, listAuthenticatedProviders } = await import('./index.js');
13
+ const { generateSmallModelText, describeSmallModel, listAuthenticatedProviders, setUnavailableRetryDelaysForTest } = await import('./index.js');
14
14
  const { configureOpenCodeRuntimeProviders, resetOpenCodeRuntimeProviders } = await import('./client.js');
15
15
 
16
16
  // A real OpenCode stub over HTTP. The module talks to OpenCode through
@@ -103,7 +103,10 @@ describe('generateSmallModelText', () => {
103
103
  const unavailable = { _tag: 'InvalidRequestError', message: 'Model unavailable: zai-coding-plan/glm-5.3-flash' };
104
104
  const options = { prompt: 'write a commit', model: 'zai-coding-plan/glm-5.3-flash' };
105
105
 
106
- it('retries a cold catalog once with the same model and prompt', async () => {
106
+ beforeEach(() => setUnavailableRetryDelaysForTest([1, 1, 1]));
107
+ afterEach(() => setUnavailableRetryDelaysForTest());
108
+
109
+ it('retries a cold catalog with the same model and prompt', async () => {
107
110
  state.generateErrors = [unavailable];
108
111
  const result = await generateSmallModelText(options);
109
112
  expect(result.text).toBe('generated');
@@ -112,12 +115,19 @@ describe('generateSmallModelText', () => {
112
115
  expect(calls[0].body).toEqual(calls[1].body);
113
116
  });
114
117
 
115
- it('reports persistent model unavailability after one retry', async () => {
116
- state.generateErrors = [unavailable, unavailable];
118
+ it('keeps backing off while the plugin model loads', async () => {
119
+ state.generateErrors = [unavailable, unavailable, unavailable];
120
+ const result = await generateSmallModelText(options);
121
+ expect(result.text).toBe('generated');
122
+ expect(state.requests.filter((entry) => entry.path === '/api/experimental/generate')).toHaveLength(4);
123
+ });
124
+
125
+ it('reports persistent model unavailability once the backoff runs out', async () => {
126
+ state.generateErrors = [unavailable, unavailable, unavailable, unavailable];
117
127
  await expect(generateSmallModelText(options)).rejects.toMatchObject({
118
128
  message: unavailable.message, statusCode: 503, code: 'small-model-unavailable',
119
129
  });
120
- expect(state.requests.filter((entry) => entry.path === '/api/experimental/generate')).toHaveLength(2);
130
+ expect(state.requests.filter((entry) => entry.path === '/api/experimental/generate')).toHaveLength(4);
121
131
  });
122
132
 
123
133
  it('does not retry other invalid requests', async () => {
@@ -133,6 +143,7 @@ describe('generateSmallModelText', () => {
133
143
  });
134
144
 
135
145
  it('cancels without sending the retry', async () => {
146
+ setUnavailableRetryDelaysForTest();
136
147
  const controller = new AbortController();
137
148
  state.generateErrors = [unavailable];
138
149
  const pending = generateSmallModelText({ ...options, signal: controller.signal });
@@ -146,6 +157,7 @@ describe('generateSmallModelText', () => {
146
157
  });
147
158
 
148
159
  it('honors the timeout during the retry delay', async () => {
160
+ setUnavailableRetryDelaysForTest();
149
161
  state.generateErrors = [unavailable];
150
162
  await expect(generateSmallModelText({ ...options, timeoutMs: 100 })).rejects.toThrow();
151
163
  expect(state.requests.filter((entry) => entry.path === '/api/experimental/generate')).toHaveLength(1);
@@ -182,11 +194,11 @@ describe('generateSmallModelText', () => {
182
194
  expect(lastGenerate().body.model).toEqual({ id: 'gpt-5.6-luna', providerID: 'openai' });
183
195
  });
184
196
 
185
- it('refuses Claude Code before any request reaches the model', async () => {
186
- await expect(generateSmallModelText({ prompt: 'hi', model: 'claude-code/haiku', directory: '/proj' }))
187
- .rejects.toMatchObject({ statusCode: 422, code: 'small-model-provider-unsupported' });
197
+ it('sends an explicit Claude Code model like any other', async () => {
198
+ const result = await generateSmallModelText({ prompt: 'hi', model: 'claude-code/haiku', directory: '/proj' });
188
199
 
189
- expect(lastGenerate()).toBeUndefined();
200
+ expect(result).toMatchObject({ providerID: 'claude-code', modelID: 'haiku', source: 'request' });
201
+ expect(lastGenerate().body.model).toEqual({ id: 'haiku', providerID: 'claude-code' });
190
202
  });
191
203
 
192
204
  it('stays on the session provider when the caller forbids switching', async () => {
@@ -276,14 +288,12 @@ describe('generateSmallModelText', () => {
276
288
  expect(result).toMatchObject({ providerID: 'my-proxy', modelID: 'gemini-3.6-flash', source: 'session-provider-small' });
277
289
  });
278
290
 
279
- it('never picks Claude Code as the small model of any provider', async () => {
291
+ it('picks Claude Code haiku as the small model like any other provider', async () => {
280
292
  state.models = [MODEL({ id: 'haiku', modelID: 'haiku', providerID: 'claude-code', family: 'claude-haiku' })];
281
293
 
282
294
  const result = await generateSmallModelText({ prompt: 'hi', directory: '/proj' });
283
295
 
284
- // Only Claude Code has a small family here, so the scan yields nothing
285
- // and OpenCode's default (the fixture's anthropic haiku) is used.
286
- expect(result).toMatchObject({ providerID: 'anthropic', modelID: 'claude-haiku-4-5', source: 'default' });
296
+ expect(result).toMatchObject({ providerID: 'claude-code', modelID: 'haiku', source: 'small' });
287
297
  });
288
298
 
289
299
  it('ignores a disabled small model and falls back to the session model', async () => {
@@ -487,10 +497,10 @@ describe('listAuthenticatedProviders', () => {
487
497
  expect(await listAuthenticatedProviders()).toEqual(['anthropic']);
488
498
  });
489
499
 
490
- it('never offers Claude Code', async () => {
500
+ it('offers Claude Code', async () => {
491
501
  state.models = [MODEL(), MODEL({ id: 'sonnet', modelID: 'sonnet', providerID: 'claude-code' })];
492
502
 
493
- expect(await listAuthenticatedProviders()).not.toContain('claude-code');
503
+ expect(await listAuthenticatedProviders()).toContain('claude-code');
494
504
  });
495
505
 
496
506
  it('answers an empty list when OpenCode is not reachable', async () => {
@@ -510,6 +520,7 @@ describe('small model failure response', () => {
510
520
  }, {
511
521
  getSmallModelService: async () => ({ generateSmallModelText }),
512
522
  });
523
+ setUnavailableRetryDelaysForTest([1]);
513
524
  state.generateErrors = [
514
525
  { _tag: 'InvalidRequestError', message: 'Model unavailable: zai-coding-plan/glm-5.3-flash' },
515
526
  { _tag: 'InvalidRequestError', message: 'Model unavailable: zai-coding-plan/glm-5.3-flash' },
@@ -526,5 +537,6 @@ describe('small model failure response', () => {
526
537
  error: 'Model unavailable: zai-coding-plan/glm-5.3-flash',
527
538
  code: 'small-model-unavailable',
528
539
  });
540
+ setUnavailableRetryDelaysForTest();
529
541
  });
530
542
  });