@vantran-se/openchamber-web 2.0.3 → 2.0.5

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 (265) hide show
  1. package/README.md +20 -28
  2. package/bin/cli.js +3 -1
  3. package/bin/cli.test.js +13 -4
  4. package/bin/lib/DOCUMENTATION.md +4 -1
  5. package/bin/lib/commands-serve.js +9 -0
  6. package/dist/assets/{CodeMirrorCommentWidgets-CWhdh0wf.js → CodeMirrorCommentWidgets-DCAQTLMM.js} +1 -1
  7. package/dist/assets/DiffView-uzNpHBlV.js +4 -0
  8. package/dist/assets/FilesView-Bkq0sjNx.js +17 -0
  9. package/dist/assets/{GitView-DKIjpnu4.js → GitView-BmibZC7n.js} +4 -4
  10. package/dist/assets/{GuestAttachDialog-BIa9jCfL.js → GuestAttachDialog-D7TJG1rt.js} +1 -1
  11. package/dist/assets/GuestSurfacePane-PAcYaFzP.js +1 -0
  12. package/dist/assets/{JsonTreeView-CtI1Mqwo.js → JsonTreeView-BNT2yUVu.js} +1 -1
  13. package/dist/assets/{LinearIssuesView-BD2zLu-r.js → LinearIssuesView-D3Azksju.js} +1 -1
  14. package/dist/assets/MarkdownImageGallery-BGY6tuiK.js +1 -0
  15. package/dist/assets/MarkdownRendererImpl-DJmhQ37f.js +12 -0
  16. package/dist/assets/{OnboardingScreen-B8TxloXZ.js → OnboardingScreen-D_41H2qC.js} +1 -1
  17. package/dist/assets/{PatchDiff--Civs1xY.js → PatchDiff-C0RSqa9W.js} +1 -1
  18. package/dist/assets/{PierreDiffViewer-BlDmw3-A.js → PierreDiffViewer-BBE7tTY2.js} +3 -3
  19. package/dist/assets/{PlanView-DyH2SyCP.js → PlanView-Bqc-xuDe.js} +4 -4
  20. package/dist/assets/PluginPane-DQ0k41Q-.js +3 -0
  21. package/dist/assets/SettingsView-BAiJU5hJ.js +30 -0
  22. package/dist/assets/{SettingsWindow-y9WpZqK6.js → SettingsWindow-DuC4ecLw.js} +1 -1
  23. package/dist/assets/{SubmoduleDiffSummary-DeFmhPFZ.js → SubmoduleDiffSummary-0HgmcMXz.js} +1 -1
  24. package/dist/assets/{ToolOutputDialog-8DmF7_PZ.js → ToolOutputDialog-DdHkQJEE.js} +6 -6
  25. package/dist/assets/{ToolPartDiffPreview-Ca15DKE1.js → ToolPartDiffPreview-DWdN0r5K.js} +2 -2
  26. package/dist/assets/{VirtualizedFileDiff-BfEBad09.js → VirtualizedFileDiff-ClDezf2d.js} +1 -1
  27. package/dist/assets/{WalkthroughView-Cj3K8Xz6.js → WalkthroughView-BJicp0RC.js} +2 -2
  28. package/dist/assets/{appThemeRegistry-DAdawJZn.js → appThemeRegistry-CiuyzfSW.js} +1 -1
  29. package/dist/assets/changesTree-CePHbFcj.js +1 -0
  30. package/dist/assets/commitAndPush-DTKb6IwT.js +1 -0
  31. package/dist/assets/{computeVirtualFileMetrics-RF-KjfUH.js → computeVirtualFileMetrics-kM_zJNMt.js} +2 -2
  32. package/dist/assets/de-BMnfXm-G.js +10 -0
  33. package/dist/assets/es-DKEvCCL6.js +16 -0
  34. package/dist/assets/fr-D9fgE2xE.js +14 -0
  35. package/dist/assets/index-DUke1nme.css +1 -0
  36. package/dist/assets/{index-CL7ZQ94-.js → index-DWB8bR_g.js} +1 -1
  37. package/dist/assets/index-Di6zUe5h.js +41 -0
  38. package/dist/assets/{isDiffPlainText-D2yJ9fO6.js → isDiffPlainText-DOoIsGqs.js} +1 -1
  39. package/dist/assets/{isFilePlainText-BwMEq1Ey.js → isFilePlainText-bt5VHvcd.js} +1 -1
  40. package/dist/assets/ja-BEXad-KW.js +16 -0
  41. package/dist/assets/ko-DGrQ0lEe.js +16 -0
  42. package/dist/assets/{main-quLSA7jc.js → main-BpLQbZC2.js} +2 -2
  43. package/dist/assets/main-CI9DtHJv.js +896 -0
  44. package/dist/assets/{markdown-shiki.worker-CahLvHIq.js → markdown-shiki.worker-Bg8SoAJJ.js} +8 -5
  45. package/dist/assets/markdownCore-DK2KK5RW.js +7 -0
  46. package/dist/assets/{miniChat-D-lJJibr.js → miniChat-CIWaR8wG.js} +2 -2
  47. package/dist/assets/{mobile-VTtphbW6.js → mobile-CnWKEYIP.js} +2 -2
  48. package/dist/assets/patchFileDiff-Cqn0C0az.js +13 -0
  49. package/dist/assets/pl-BlhkmR5b.js +16 -0
  50. package/dist/assets/pt-BR-Bs96Ew9I.js +16 -0
  51. package/dist/assets/{renderElectronMiniChatApp-DF7ch5eM.js → renderElectronMiniChatApp-D9t0-9nV.js} +2 -2
  52. package/dist/assets/renderMobileApp-CW7fVs_s.js +25 -0
  53. package/dist/assets/runtimeEndpointReset-D0PcCb3K.js +27 -0
  54. package/dist/assets/scheduledTasksApi-Dahv3ws0.js +1 -0
  55. package/dist/assets/{shikiHighlight-_HeCCHx1.js → shikiHighlight-l-MbaNS4.js} +1 -1
  56. package/dist/assets/{textMateThemeFromAppTheme-C95XdFGj.js → textMateThemeFromAppTheme-C-kGcOpD.js} +1 -1
  57. package/dist/assets/tr-CehUK1M9.js +7 -0
  58. package/dist/assets/uk-Bno5WGNz.js +16 -0
  59. package/dist/assets/{useAppFontEffects-ZM9ij19-.js → useAppFontEffects-DDjuwVqi.js} +160 -156
  60. package/dist/assets/{useInlineCommentController-CkfxMJPy.js → useInlineCommentController-Clg8EO3A.js} +2 -2
  61. package/dist/assets/{useRootScrollLock-BtiAC1fj.js → useRootScrollLock-nDcRrgJ1.js} +2 -2
  62. package/dist/assets/{vendor-codemirror-language-data-U-lXzXNK.js → vendor-codemirror-language-data-oKF0MAwd.js} +2 -2
  63. package/dist/assets/vendor-opencode-client-DSZl7jSi.js +12 -0
  64. package/dist/assets/vendor-zod-BSoePkpP.js +39 -0
  65. package/dist/assets/{web-components-C583Pmfm.js → web-components-Bc1WPd4i.js} +1 -1
  66. package/dist/assets/zh-CN-DnAweIWo.js +16 -0
  67. package/dist/assets/zh-TW-BbBBK25p.js +16 -0
  68. package/dist/index.html +5 -5
  69. package/dist/mini-chat.html +5 -5
  70. package/dist/mobile.html +5 -5
  71. package/package.json +3 -3
  72. package/server/index.d.ts +2 -0
  73. package/server/index.js +196 -52
  74. package/server/lib/agent-tool/DOCUMENTATION.md +5 -0
  75. package/server/lib/agent-tool/runtime.js +89 -51
  76. package/server/lib/agent-tool/runtime.test.js +17 -0
  77. package/server/lib/browser-control/DOCUMENTATION.md +14 -2
  78. package/server/lib/event-stream/DOCUMENTATION.md +2 -2
  79. package/server/lib/event-stream/global-hub.js +45 -7
  80. package/server/lib/event-stream/global-hub.test.js +128 -2
  81. package/server/lib/event-stream/global-ws-bridge.js +3 -1
  82. package/server/lib/event-stream/rebind.test.js +1 -0
  83. package/server/lib/event-stream/runtime.js +1 -2
  84. package/server/lib/event-stream/upstream-reader.js +3 -2
  85. package/server/lib/git/DOCUMENTATION.md +1 -1
  86. package/server/lib/git/service.js +22 -0
  87. package/server/lib/git/service.test.js +8 -0
  88. package/server/lib/guests/DOCUMENTATION.md +4 -3
  89. package/server/lib/guests/catalog.js +19 -1
  90. package/server/lib/guests/catalog.test.js +22 -0
  91. package/server/lib/guests/html-styles.js +5 -3
  92. package/server/lib/guests/html-styles.test.js +3 -1
  93. package/server/lib/guests/routes.js +11 -3
  94. package/server/lib/guests/service.js +5 -0
  95. package/server/lib/guests/surface.js +55 -9
  96. package/server/lib/guests/surface.test.js +56 -6
  97. package/server/lib/notifications/DOCUMENTATION.md +10 -0
  98. package/server/lib/notifications/emit-route.js +118 -0
  99. package/server/lib/notifications/emit-route.test.js +112 -0
  100. package/server/lib/openchamber-control/actions.js +16 -2
  101. package/server/lib/openchamber-control/service.js +23 -0
  102. package/server/lib/openchamber-control/service.test.js +31 -0
  103. package/server/lib/openchamber-sessions/fork-inheritance.js +103 -0
  104. package/server/lib/openchamber-sessions/fork-inheritance.test.js +69 -0
  105. package/server/lib/openchamber-sessions/routes.js +11 -0
  106. package/server/lib/openchamber-sessions/routes.test.js +18 -0
  107. package/server/lib/opencode/DOCUMENTATION.md +23 -23
  108. package/server/lib/opencode/bootstrap-runtime.js +14 -1
  109. package/server/lib/opencode/compatibility.js +6 -3
  110. package/server/lib/opencode/compatibility.test.js +8 -0
  111. package/server/lib/opencode/config-entity-v2.test.js +27 -0
  112. package/server/lib/opencode/config-paths.test.js +12 -0
  113. package/server/lib/opencode/config-v2.d.ts +2 -0
  114. package/server/lib/opencode/config-v2.js +42 -0
  115. package/server/lib/opencode/config-v2.test.js +17 -0
  116. package/server/lib/opencode/core-routes.js +20 -4
  117. package/server/lib/opencode/core-routes.test.js +41 -0
  118. package/server/lib/opencode/env-runtime.js +1 -8
  119. package/server/lib/opencode/feature-routes-runtime.js +2 -1
  120. package/server/lib/opencode/lifecycle.js +156 -2
  121. package/server/lib/opencode/lifecycle.test.js +255 -1
  122. package/server/lib/opencode/managed-config-file.js +18 -6
  123. package/server/lib/opencode/managed-config-file.test.js +23 -7
  124. package/server/lib/opencode/mcp.js +7 -2
  125. package/server/lib/opencode/network-runtime.js +16 -7
  126. package/server/lib/opencode/network-runtime.test.js +29 -1
  127. package/server/lib/opencode/npm-registry-config.d.ts +1 -0
  128. package/server/lib/opencode/npm-registry-config.js +123 -0
  129. package/server/lib/opencode/npm-registry-config.test.js +167 -0
  130. package/server/lib/opencode/npm-registry.js +5 -6
  131. package/server/lib/opencode/npm-registry.test.js +30 -0
  132. package/server/lib/opencode/openchamber-routes.js +29 -11
  133. package/server/lib/opencode/openchamber-routes.test.js +18 -0
  134. package/server/lib/opencode/project-directory-runtime.js +8 -0
  135. package/server/lib/opencode/project-directory-runtime.test.js +13 -0
  136. package/server/lib/opencode/provider-source-route.test.js +59 -0
  137. package/server/lib/opencode/providers.js +3 -0
  138. package/server/lib/opencode/providers.test.js +62 -0
  139. package/server/lib/opencode/proxy.js +53 -16
  140. package/server/lib/opencode/routes-upgrade.test.js +1 -1
  141. package/server/lib/opencode/routes.js +36 -1
  142. package/server/lib/opencode/server-startup-runtime.js +25 -0
  143. package/server/lib/opencode/server-startup-runtime.test.js +67 -1
  144. package/server/lib/opencode/server-utils-runtime.js +5 -0
  145. package/server/lib/opencode/settings-helpers.js +10 -0
  146. package/server/lib/opencode/settings-helpers.test.js +2 -2
  147. package/server/lib/opencode/settings-registry.json +17 -0
  148. package/server/lib/opencode/settings-runtime.js +1 -0
  149. package/server/lib/opencode/shared-service-runtime.js +155 -0
  150. package/server/lib/opencode/shared-service-runtime.test.js +180 -0
  151. package/server/lib/opencode/shared.js +49 -4
  152. package/server/lib/opencode/shared.test.js +78 -1
  153. package/server/lib/opencode/shutdown-runtime.js +14 -5
  154. package/server/lib/opencode/shutdown-runtime.test.js +77 -2
  155. package/server/lib/opencode/skill-routes.js +28 -12
  156. package/server/lib/opencode/skill-routes.test.js +121 -1
  157. package/server/lib/opencode/startup-pipeline-runtime.js +5 -4
  158. package/server/lib/opencode/startup-pipeline-runtime.test.js +18 -7
  159. package/server/lib/opencode/upgrade-capability.js +0 -14
  160. package/server/lib/opencode/v1-migration-topup.js +21 -15
  161. package/server/lib/opencode/v1-migration-topup.test.js +14 -6
  162. package/server/lib/opencode/v2-install.d.ts +1 -0
  163. package/server/lib/opencode/v2-install.js +8 -7
  164. package/server/lib/opencode/v2-install.test.js +13 -4
  165. package/server/lib/opencode/watcher.js +3 -1
  166. package/server/lib/opencode/watcher.test.js +54 -0
  167. package/server/lib/opencode/websearch-config.js +13 -1
  168. package/server/lib/quota/providers/kimi.js +1 -1
  169. package/server/lib/realtime-proxy.js +5 -1
  170. package/server/lib/realtime-proxy.test.js +29 -0
  171. package/server/lib/relay/tunnel-host.js +4 -1
  172. package/server/lib/relay/tunnel-host.test.js +11 -0
  173. package/server/lib/session-assist/DOCUMENTATION.md +17 -5
  174. package/server/lib/session-assist/runtime.js +19 -2
  175. package/server/lib/session-assist/runtime.test.js +8 -0
  176. package/server/lib/session-goal/DOCUMENTATION.md +11 -0
  177. package/server/lib/small-model/DOCUMENTATION.md +11 -10
  178. package/server/lib/small-model/index.js +27 -32
  179. package/server/lib/small-model/index.test.js +28 -16
  180. package/server/lib/spaces/DOCUMENTATION.md +404 -32
  181. package/server/lib/spaces/code-in-bait.js +145 -6
  182. package/server/lib/spaces/code-in.js +10 -150
  183. package/server/lib/spaces/code-in.test.js +7 -83
  184. package/server/lib/spaces/code-out.js +1819 -0
  185. package/server/lib/spaces/code-out.test.js +3945 -0
  186. package/server/lib/spaces/code-transfer.js +173 -0
  187. package/server/lib/spaces/command-stream.test.js +181 -0
  188. package/server/lib/spaces/dispatcher.js +670 -0
  189. package/server/lib/spaces/dispatcher.test.js +582 -0
  190. package/server/lib/spaces/gatekeeper-channel.js +61 -7
  191. package/server/lib/spaces/gatekeeper-channel.test.js +43 -7
  192. package/server/lib/spaces/gatekeeper-program.cjs +68 -14
  193. package/server/lib/spaces/gatekeeper-program.test.js +118 -3
  194. package/server/lib/spaces/hardening.js +20 -4
  195. package/server/lib/spaces/hardening.test.js +18 -3
  196. package/server/lib/spaces/host-git.js +7 -4
  197. package/server/lib/spaces/host-git.test.js +6 -2
  198. package/server/lib/spaces/host.js +310 -0
  199. package/server/lib/spaces/host.test.js +273 -0
  200. package/server/lib/spaces/journey.js +552 -0
  201. package/server/lib/spaces/journey.test.js +589 -0
  202. package/server/lib/spaces/layout.js +42 -8
  203. package/server/lib/spaces/manager.js +4 -3
  204. package/server/lib/spaces/manager.test.js +6 -0
  205. package/server/lib/spaces/places/code-out.docker.live.test.js +412 -0
  206. package/server/lib/spaces/places/contract-suite.js +36 -0
  207. package/server/lib/spaces/places/dispatcher.docker.live.test.js +296 -0
  208. package/server/lib/spaces/places/docker-live-support.js +27 -4
  209. package/server/lib/spaces/places/docker.js +33 -5
  210. package/server/lib/spaces/places/docker.test.js +42 -3
  211. package/server/lib/spaces/places/escape-suite.js +118 -14
  212. package/server/lib/spaces/places/fake-docker.js +3 -1
  213. package/server/lib/spaces/places/grants.docker.live.test.js +197 -0
  214. package/server/lib/spaces/places/memory-place.js +24 -1
  215. package/server/lib/spaces/places/registry.js +1 -1
  216. package/server/lib/spaces/places/server.docker.live.test.js +69 -33
  217. package/server/lib/spaces/places/sessions.docker.live.test.js +222 -0
  218. package/server/lib/spaces/routes.js +216 -0
  219. package/server/lib/spaces/routes.test.js +213 -0
  220. package/server/lib/spaces/run-command.js +261 -8
  221. package/server/lib/spaces/run-command.test.js +193 -3
  222. package/server/lib/spaces/space-events.js +153 -0
  223. package/server/lib/spaces/space-events.test.js +160 -0
  224. package/server/lib/spaces/space-opencode.js +52 -0
  225. package/server/lib/spaces/space-opencode.test.js +41 -0
  226. package/server/lib/spaces/space-records.js +150 -0
  227. package/server/lib/spaces/space-records.test.js +113 -0
  228. package/server/lib/spaces/space-sessions.js +210 -0
  229. package/server/lib/spaces/space-sessions.test.js +161 -0
  230. package/server/lib/spaces/websocket.js +329 -0
  231. package/server/lib/spaces/websocket.test.js +345 -0
  232. package/server/lib/ui-auth/ui-auth.js +8 -2
  233. package/server/lib/ui-auth/ui-auth.test.js +29 -0
  234. package/server/opencode-proxy.test.js +271 -0
  235. package/server/proxy-headers.js +8 -2
  236. package/server/proxy-headers.test.js +20 -0
  237. package/dist/assets/DiffView-CX3wAX2S.js +0 -4
  238. package/dist/assets/FilesView-OL3CON3r.js +0 -17
  239. package/dist/assets/GuestSurfacePane-B_cqX5I5.js +0 -1
  240. package/dist/assets/MarkdownImageGallery-DFiTFZgj.js +0 -1
  241. package/dist/assets/MarkdownRendererImpl-DZm5sf1W.js +0 -12
  242. package/dist/assets/PluginPane-BibJit86.js +0 -3
  243. package/dist/assets/SettingsView-h-VpVO47.js +0 -29
  244. package/dist/assets/commitAndPush-BIPfOMXY.js +0 -1
  245. package/dist/assets/de-BqCNGROu.js +0 -10
  246. package/dist/assets/es-BTPpTRgN.js +0 -16
  247. package/dist/assets/fr-hxbzMdAg.js +0 -14
  248. package/dist/assets/index-CLcM8HKx.css +0 -1
  249. package/dist/assets/index-CvLgnuNI.js +0 -41
  250. package/dist/assets/ja-_zLwwYiy.js +0 -16
  251. package/dist/assets/ko-CFn3Dzhc.js +0 -16
  252. package/dist/assets/main-BLJaug9I.js +0 -896
  253. package/dist/assets/markdownCore-DS9FdwrI.js +0 -5
  254. package/dist/assets/patchFileDiff-D9iBcTEi.js +0 -13
  255. package/dist/assets/pl-DFHE7xac.js +0 -16
  256. package/dist/assets/pt-BR-CUjZF179.js +0 -16
  257. package/dist/assets/renderMobileApp-CNQhdXsi.js +0 -25
  258. package/dist/assets/runtimeEndpointReset-DeaERc0M.js +0 -27
  259. package/dist/assets/scheduledTasksApi-BD6Vx9pu.js +0 -1
  260. package/dist/assets/tr-BB6sA_zH.js +0 -7
  261. package/dist/assets/uk-CB4o8g-o.js +0 -16
  262. package/dist/assets/vendor-opencode-client-_BAHQo4R.js +0 -12
  263. package/dist/assets/vendor-zod-Gea9uBlY.js +0 -39
  264. package/dist/assets/zh-CN-BRyDuWhc.js +0 -16
  265. package/dist/assets/zh-TW-BDGbWA1Q.js +0 -16
@@ -0,0 +1,69 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import { applyForkInheritance, sourceOwnedLinksPatch } from './fork-inheritance.js';
3
+
4
+ const goal = (objectiveFile) => ({ id: 'goal_1', objective: objectiveFile ? '' : 'Ship it', objectiveFile, status: 'paused' });
5
+
6
+ const harness = (metadata, overrides = {}) => {
7
+ const deps = {
8
+ readObjective: vi.fn(async () => 'Objective from file'),
9
+ writeObjective: vi.fn(async () => undefined),
10
+ writeMetadata: vi.fn(async () => undefined),
11
+ ...overrides,
12
+ };
13
+ const run = () => applyForkInheritance({ sourceSessionID: 'ses_source', fork: { id: 'ses_fork', metadata }, ...deps });
14
+ return { deps, run };
15
+ };
16
+
17
+ describe('sourceOwnedLinksPatch', () => {
18
+ it('pauses an active goal so the fork does not pursue it in parallel', () => {
19
+ expect(sourceOwnedLinksPatch({ openchamber: { goal: { id: 'g', status: 'active' } } }))
20
+ .toEqual({ goal: { status: 'paused', statusReason: 'paused in fork' } });
21
+ expect(sourceOwnedLinksPatch({ openchamber: { goal: { id: 'g', status: 'complete' } } })).toBeNull();
22
+ });
23
+
24
+ it('removes the btw and review links but keeps the goal', () => {
25
+ expect(sourceOwnedLinksPatch({ openchamber: { goal: goal(false), btwSessionID: 'b', reviewSessionID: 'r' } }))
26
+ .toEqual({ btwSessionID: null, reviewSessionID: null });
27
+ });
28
+
29
+ it('removes the btw marker of a btw source, keeps a review marker', () => {
30
+ expect(sourceOwnedLinksPatch({ openchamber: { kind: 'btw', originalSessionID: 'p', btwBoundaryMessageID: 'm' } }))
31
+ .toEqual({ kind: null, originalSessionID: null, btwBoundaryMessageID: null });
32
+ expect(sourceOwnedLinksPatch({ openchamber: { kind: 'review', originalSessionID: 'p' } })).toBeNull();
33
+ expect(sourceOwnedLinksPatch(undefined)).toBeNull();
34
+ });
35
+ });
36
+
37
+ describe('applyForkInheritance', () => {
38
+ it('does nothing for an inline goal without source links', async () => {
39
+ const h = harness({ openchamber: { goal: goal(false) } });
40
+ await h.run();
41
+ expect(h.deps.readObjective).not.toHaveBeenCalled();
42
+ expect(h.deps.writeMetadata).not.toHaveBeenCalled();
43
+ });
44
+
45
+ it('copies a file-backed objective and strips source links', async () => {
46
+ const h = harness({ openchamber: { goal: goal(true), btwSessionID: 'b' } });
47
+ await h.run();
48
+ expect(h.deps.readObjective).toHaveBeenCalledWith('ses_source');
49
+ expect(h.deps.writeObjective).toHaveBeenCalledWith('ses_fork', 'Objective from file');
50
+ expect(h.deps.writeMetadata).toHaveBeenCalledWith('ses_fork', { openchamber: { btwSessionID: null } });
51
+ });
52
+
53
+ it('inlines the objective when the file copy fails', async () => {
54
+ const h = harness({ openchamber: { goal: goal(true) } }, {
55
+ writeObjective: vi.fn(async () => { throw new Error('disk full'); }),
56
+ });
57
+ await h.run();
58
+ expect(h.deps.writeMetadata).toHaveBeenCalledWith('ses_fork', {
59
+ openchamber: { goal: { objective: 'Objective from file', objectiveFile: false } },
60
+ });
61
+ });
62
+
63
+ it('swallows a metadata write failure', async () => {
64
+ const h = harness({ openchamber: { reviewSessionID: 'r' } }, {
65
+ writeMetadata: vi.fn(async () => { throw new Error('offline'); }),
66
+ });
67
+ await expect(h.run()).resolves.toBeUndefined();
68
+ });
69
+ });
@@ -9,7 +9,9 @@ import { AUTO_MODEL_REF, isAutoModel } from '../routing/defaults.js';
9
9
  import { parseScheduledCommandPrompt } from '../scheduled-tasks/runtime.js';
10
10
  import { buildGoalIntroText, createSessionGoal } from '../session-goal/create.js';
11
11
  import { OpenChamberControlError, asControlError } from '../openchamber-control/error.js';
12
+ import { readObjective, writeObjective } from '../session-goal/objectives.js';
12
13
  import { createArchiveStore } from './archive-store.js';
14
+ import { applyForkInheritance } from './fork-inheritance.js';
13
15
  import { createOpenCodeClient as defaultCreateOpenCodeClient } from './opencode-client.js';
14
16
  import { createSessionMetadataStore, createOpenCodeSessionMetadata } from './session-metadata-store.js';
15
17
 
@@ -889,6 +891,15 @@ export const createOpenChamberSessionService = (dependencies) => {
889
891
  messageID: asNonEmptyString(payload.messageId) || undefined,
890
892
  });
891
893
  targetSessionID = targetSession.id;
894
+ // Before the prompt goes out, so a goal armed by this dispatch writes
895
+ // over the copied objective rather than the other way round.
896
+ await applyForkInheritance({
897
+ sourceSessionID,
898
+ fork: targetSession,
899
+ readObjective,
900
+ writeObjective,
901
+ writeMetadata: (sessionID, patch) => writeMetadata(sessionID, patch, directory),
902
+ });
892
903
  }
893
904
 
894
905
  const baselineAssistantMessageId = await latestCompletedAssistantMessageID({
@@ -920,6 +920,24 @@ describe('openchamber session routes', () => {
920
920
  }));
921
921
  });
922
922
 
923
+ it('strips source-owned links from a fork before dispatching', async () => {
924
+ sessionForkMock.mockImplementationOnce(async () => ({
925
+ id: 'ses_fork',
926
+ title: 'Forked session',
927
+ metadata: { openchamber: { btwSessionID: 'ses_btw', reviewSessionID: 'ses_review', assist: { recap: 'kept' } } },
928
+ }));
929
+ const { app, sessionMetadataStore } = createApp();
930
+ sessionMetadataStore.entries.set('ses_fork', {
931
+ openchamber: { btwSessionID: 'ses_btw', reviewSessionID: 'ses_review', assist: { recap: 'kept' } },
932
+ });
933
+ await request(app)
934
+ .post('/api/openchamber/sessions/ses_source/fork')
935
+ .send({ directory: '/repo/app', prompt: 'Carry on', model: 'openai/gpt-5.5', agent: 'build' })
936
+ .expect(200);
937
+
938
+ expect(sessionMetadataStore.entries.get('ses_fork')).toEqual({ openchamber: { assist: { recap: 'kept' } } });
939
+ });
940
+
923
941
  it('rejects send and fork requests without a prompt before calling OpenCode', async () => {
924
942
  const { app } = createApp();
925
943
  await request(app)
@@ -11,13 +11,14 @@ This module provides OpenCode server integration utilities for the web server ru
11
11
  - `packages/web/server/lib/opencode/cli-entry-runtime.js`: CLI entrypoint runtime that detects direct execution, parses CLI options, and starts server bootstrap.
12
12
  - `packages/web/server/lib/opencode/routes.js`: OpenCode/provider settings and auth-related route registration.
13
13
  - `packages/web/server/lib/opencode/v1-migration-topup.js`: re-arms OpenCode's own V1 -> V2 session import for V1 sessions created after that migration already completed; runs only before a managed spawn. See "v1-migration-topup.js" below.
14
- - `packages/web/server/lib/opencode/lifecycle.js`: OpenCode process lifecycle runtime (startup, restart, readiness, health monitoring). After readiness it warms the most recently used directories (`getWarmupDirectories` dep, sequential and best-effort) because OpenCode initializes each directory lazily on first request and that cost would otherwise be paid by the user's first interactive session open.
14
+ - `packages/web/server/lib/opencode/lifecycle.js`: OpenCode connection policy and the managed process lifecycle runtime. The composition helper selects `explicit-external` when `OPENCODE_HOST` is configured, `managed-owned` for Desktop, and `shared-local` for ordinary Web. The managed path still owns startup, restart, readiness, health monitoring, orphan reaping, credentials, and V1 migration top-up. After readiness it warms the most recently used directories (`getWarmupDirectories` dep, sequential and best-effort) because OpenCode initializes each directory lazily on first request and that cost would otherwise be paid by the user's first interactive session open.
15
15
  - `packages/web/server/lib/opencode/provider-env-aliases.js`: mirrors known provider credential env aliases into the managed OpenCode process environment (for example `GEMINI_API_KEY` → `GOOGLE_GENERATIVE_AI_API_KEY`) so OpenCode connection detection and the upstream AI SDK agree on the same key names. Canonical implementation shared by web lifecycle and the VS Code managed spawn path (`packages/vscode/src/provider-env-aliases.ts` re-exports this module).
16
16
  - `packages/web/server/lib/opencode/env-runtime.js`: OpenCode CLI/binary resolution and shell environment runtime.
17
17
  - `packages/web/server/lib/opencode/env-config.js`: OpenCode-related environment variable parsing and validation (host/port/hostname).
18
18
  - `packages/web/server/lib/opencode/hmr-state-runtime.js`: HMR-persistent runtime state initialization, auth-state bootstrap, and HMR sync helpers.
19
19
  - `packages/web/server/lib/opencode/bootstrap-runtime.js`: base app bootstrap runtime for status/auth/tts/notification/OpenChamber route wiring.
20
- - `packages/web/server/lib/opencode/network-runtime.js`: OpenCode URL construction, health-probe readiness checks, and API prefix runtime.
20
+ - `packages/web/server/lib/opencode/network-runtime.js`: OpenCode URL construction, health-probe readiness checks, and API prefix runtime. URL and service headers are resolved for every request so shared-service endpoint or credential rotation is immediate; lowercase service `authorization` is normalized to `Authorization` while other service headers are preserved.
21
+ - `packages/web/server/lib/opencode/shared-service-runtime.js`: discovery-first connection used by ordinary Web when no explicit `OPENCODE_HOST` is configured. It probes `/api/info` before accepting an endpoint, refuses to replace an incompatible discovered service, and never owns or stops the shared process. Because the pinned client's discovery hides incompatible and unhealthy registrations, Web checks that the registration file is genuinely absent before calling its destructive `ensure`; an existing hidden registration fails startup untouched. Shared restart and health controls recover the connection, then rebind live event readers so their established reconnect edge repairs authoritative client state without synthetic interruption. Desktop does not instantiate this runtime.
21
22
  - `packages/web/server/lib/opencode/project-directory-runtime.js`: request-scoped and settings-backed project directory resolution/validation runtime.
22
23
  - `packages/web/server/lib/opencode/config-entity-routes.js`: route registration for agent/command/MCP config orchestration. OpenCode 2 watches these files, so a write is live as soon as it lands and the route answers plain success.
23
24
  - `packages/web/server/lib/opencode/websearch-config.js`: writes OpenCode's `websearch` choice (`PUT /api/config/websearch` in `routes.js`; body `{ selection: false | null | "random" | "<provider id>" }`, `null` removes the key, so OpenCode falls back to the answer given in its chat consent form, which it keeps in its own store) to `OPENCODE_CONFIG` when set, else the user config. `GET /api/config/websearch` returns `{ projectPath }`: the project config whose `websearch` overrides that write (OpenCode merges user < project < `OPENCODE_CONFIG`), so Settings disables the choice and names the file instead of letting it snap back; `findWebSearchProjectOverride` in `config-v2.js` holds the rule. The pure transform is `writeWebSearchSelection` in `config-v2.js`, shared with the VS Code bridge (`api:config/websearch`).
@@ -25,18 +26,18 @@ This module provides OpenCode server integration utilities for the web server ru
25
26
  - `packages/web/server/lib/opencode/snippets.js`: opencode-snippets-compatible snippet file CRUD, discovery, and hashtag expansion.
26
27
  - `packages/web/server/lib/opencode/cli-options.js`: CLI/environment option parsing for server startup arguments.
27
28
  - `packages/web/server/lib/opencode/core-routes.js`: server status/system routes, auth/access guard routes, and settings utility route registration.
28
- - `packages/web/server/lib/opencode/shutdown-runtime.js`: graceful shutdown orchestration runtime for watcher/session/guest-services/terminal/process/server teardown.
29
- - `packages/web/server/lib/opencode/server-startup-runtime.js`: server listen/startup tunnel flow and process/signal handler orchestration runtime.
30
- - `packages/web/server/lib/opencode/static-routes-runtime.js`: static asset/SPA fallback route registration and manifest route wiring.
31
- - `packages/web/server/lib/opencode/feature-routes-runtime.js`: feature route composition runtime for dynamic import-backed config/skill/provider route registration.
32
- - `packages/web/server/lib/opencode/opencode-resolution-runtime.js`: OpenCode binary resolution snapshot runtime for settings routes and diagnostics.
33
- - `packages/web/server/lib/opencode/upgrade-capability.js`: authoritative upgrade ownership policy for the active OpenCode runtime. Bundled, external, and unresolved runtimes fail closed; only managed non-bundled runtimes delegate upgrades to OpenCode.
34
- - `packages/web/server/lib/opencode/tunnel-wiring-runtime.js`: tunnel service/routes composition runtime and active-port wiring for main server startup.
35
- - `packages/web/server/lib/opencode/startup-pipeline-runtime.js`: server startup tail orchestration runtime for terminal/proxy/static/start-listen flow.
29
+ - `packages/web/server/lib/opencode/shutdown-runtime.js`: graceful shutdown orchestration. Only `managed-owned` may close an OpenCode child, kill by port, or wait for port release. Shared and external connections dispose local state only.
30
+ - `packages/web/server/lib/opencode/server-startup-runtime.js`: server listen, startup tunnel flow, and process or signal handler orchestration.
31
+ - `packages/web/server/lib/opencode/static-routes-runtime.js`: static asset and SPA fallback route registration.
32
+ - `packages/web/server/lib/opencode/feature-routes-runtime.js`: feature route composition for dynamic route modules.
33
+ - `packages/web/server/lib/opencode/opencode-resolution-runtime.js`: OpenCode binary resolution snapshot for settings routes and diagnostics.
34
+ - `packages/web/server/lib/opencode/upgrade-capability.js`: upgrade ownership policy. Bundled, external, and unresolved runtimes fail closed; only managed non-bundled runtimes delegate upgrades to OpenCode.
35
+ - `packages/web/server/lib/opencode/tunnel-wiring-runtime.js`: tunnel service and active-port wiring.
36
+ - `packages/web/server/lib/opencode/startup-pipeline-runtime.js`: terminal, proxy, static route, and listen orchestration. Shared Web starts no managed-tool installation or registration step.
36
37
  - `packages/web/server/lib/opencode/startup-performance.js`: opt-in startup phase diagnostics with fixed labels and numeric metadata allowlists.
37
- - `packages/web/server/lib/agent-tool/runtime.js`: managed OpenCode custom-tool materialization, environment injection, same-machine authentication (loopback, or the bound address for a concrete bind), and fixed CLI action dispatch.
38
- - `packages/web/server/lib/opencode/managed-plugin-config.js`: the `OPENCODE_CONFIG_CONTENT` merge used only on the fallback path, when the user's own environment owns `OPENCODE_CONFIG`.
39
- - `packages/web/server/lib/opencode/managed-config-file.js`: the managed OpenCode config layer — materializes the enabled OpenChamber plugins (agent tools, system prompt optimizer) and publishes them in a file OpenCode watches.
38
+ - `packages/web/server/lib/agent-tool/runtime.js`: Desktop's custom-tool schema and source generation, managed plugin materialization, environment injection, same-machine authentication, and fixed action dispatch.
39
+ - `packages/web/server/lib/opencode/managed-plugin-config.js`: the `OPENCODE_CONFIG_CONTENT` merge used only when the user's environment owns `OPENCODE_CONFIG`.
40
+ - `packages/web/server/lib/opencode/managed-config-file.js`: Desktop's managed OpenCode config layer for environment-backed OpenChamber tools and the system prompt optimizer.
40
41
 
41
42
  ### Managed plugins on OpenCode 2.x
42
43
  A configured plugin must be a DIRECTORY holding a `package.json` that resolves an
@@ -296,7 +297,8 @@ Installer output is discarded, not forwarded to clients or logs.
296
297
  The runtime maintains active-session count incrementally from idempotent activity phase transitions. Upstream stall-timeout and lifecycle health checks read it in O(1); the hourly cleanup removes activity phases older than 24 hours without broadcasting synthetic state transitions. Snapshot generation remains reserved for the session-activity API.
297
298
 
298
299
  ## Public exports (lifecycle.js)
299
- - `createOpenCodeLifecycleRuntime(dependencies)`: creates lifecycle runtime for managed/external OpenCode process orchestration. The optional `onOpenCodeRestarted` dependency (default `null`) is fired after a successful managed restart. `index.js` rebinds event-stream readers to the possibly-new port (#2638), then calls `interruptBusySessionsAfterRestart()` and broadcasts one `opencode-restart-interrupted` UI notification when interrupted turns exist (#2943).
300
+ - `createOpenCodeRecoveryCallbacks(dependencies)`: composes the production recovery callbacks used by `index.js`. Managed restart resets providers, rebinds event readers, calls `interruptBusySessionsAfterRestart()`, and broadcasts the existing interruption notification. Shared recovery resets providers and rebinds readers only, leaving authoritative browser reconnect repair to the existing second `ready` edge.
301
+ - `createOpenCodeLifecycleRuntime(dependencies)`: creates lifecycle runtime for managed/external OpenCode process orchestration. The optional `onOpenCodeRestarted` dependency (default `null`) is fired after a successful managed restart. `index.js` supplies the managed callback above.
300
302
  - Returned API:
301
303
  - `startOpenCode()`
302
304
  - `restartOpenCode()`
@@ -396,7 +398,7 @@ ConPTY or Console Window Host behavior.
396
398
  - `setupBaseRoutes(app, options)`
397
399
 
398
400
  ## Public exports (network-runtime.js)
399
- - `createOpenCodeNetworkRuntime(dependencies)`: creates runtime for OpenCode network and URL concerns.
401
+ - `createOpenCodeNetworkRuntime(dependencies)`: creates runtime for OpenCode network and URL concerns. `getOpenCodeBaseUrl` and `getOpenCodeAuthHeaders` are read at call time; credentials remain request headers and never enter generated URLs.
400
402
  - Returned API:
401
403
  - `waitForReady(url, timeoutMs?)`
402
404
  - `normalizeApiPrefix(prefix)`
@@ -751,15 +753,15 @@ headers }` or v1 `{ npm, options }`. The stored entry is always a
751
753
  - `setupProxy(app)`
752
754
 
753
755
  ## Public exports (shutdown-runtime.js)
754
- - `createGracefulShutdownRuntime(dependencies)`: creates graceful shutdown runtime for managed OpenCode and web server teardown sequencing.
755
- - Daemon signals, `POST /api/system/shutdown`, and embedded `stop()` share one shutdown promise. Guest admission closes synchronously before any await. Cleanup stops the relay reconciliation timer, guest viewers, realtime proxy, relay host, dictation worker and session runtimes before draining guest services, including pending starts. Each cleanup is best-effort and runs once per shutdown, including after partial startup. A hard kill still requires the separate crash/SIGKILL recovery work; no persistent registry or boot reaper is provided here.
756
- - Register TCP connection tracking before the HTTP server starts listening. After stopping owned runtimes and OpenCode, HTTP shutdown closes the listener and all remaining sockets, including WebSocket upgrades and unanswered upgrade requests accepted during cleanup. This prevents client reconnects from holding Desktop open until the HTTP close deadline. Each runtime still owns its protocol cleanup; socket teardown runs afterwards and preserves the existing terminal and process grace periods.
756
+ - `createGracefulShutdownRuntime(dependencies)`: creates graceful shutdown for Web-owned resources and explicit OpenCode process ownership.
757
+ - Daemon signals, `POST /api/system/shutdown`, and embedded `stop()` share one shutdown promise. Guest admission closes before asynchronous cleanup. Each cleanup is best-effort and runs once, including after partial startup. The application-owned global event hub is stopped here after its watcher, so embedded shutdown aborts the upstream event fetch and reconnect loop; watcher and WebSocket bridges do not stop injected hubs.
758
+ - Only `managed-owned` may close the OpenCode child, kill by port, or wait for port release. `shared-local` disposes only local connection state. `explicit-external` performs no OpenCode process teardown. TCP connection tracking closes the Web listener and remaining sockets after owned runtimes stop, while shared or external OpenCode and TUI work continue.
757
759
  - Returned API:
758
760
  - `gracefulShutdown(options?)`
759
761
  - `trackServerConnections(server)`: call once before listening; closed sockets leave the tracking set, and the server close event removes the connection listener.
760
762
 
761
763
  ## Public exports (server-startup-runtime.js)
762
- - `createServerStartupRuntime(dependencies)`: creates runtime for server bind/startup tunnel and process handler wiring.
764
+ - `createServerStartupRuntime(dependencies)`: creates runtime for server bind/startup tunnel and process handler wiring. Daemon readiness is emitted only after listener-dependent startup completes. A startup failure sends `openchamber:error`, closes the listener and accepted connections, and rejects startup without a ready event.
763
765
  - Returned API:
764
766
  - `resolveBindHost(host)`
765
767
  - `startListeningAndMaybeTunnel(options)`
@@ -786,13 +788,11 @@ headers }` or v1 `{ npm, options }`. The stored entry is always a
786
788
  - `initialize(app, initialPort, hasUiPassword)`
787
789
 
788
790
  ## Public exports (startup-pipeline-runtime.js)
789
- - `createStartupPipelineRuntime(dependencies)`: creates runtime for terminal wiring, proxy/bootstrap scheduling, static route registration, and server startup/listen flow.
791
+ - `createStartupPipelineRuntime(dependencies)`: creates terminal, proxy, static route, and server listen orchestration.
790
792
  - Returned API:
791
793
  - `run(options)`
792
794
 
793
- The pipeline binds the OpenChamber listener and publishes its active port
794
- before starting managed OpenCode. The managed custom tool therefore receives
795
- an authoritative loopback callback URL even when OpenChamber binds port `0`.
795
+ The pipeline publishes the listener port before bootstrapping OpenCode, then waits for connection authentication and version validation before daemon readiness. Desktop's generated custom tool therefore receives the correct loopback callback URL even when OpenChamber binds port `0`, while ordinary Web cannot advertise readiness with an unusable shared service. Shared Web does not install a plugin or publish callback registration.
796
796
 
797
797
  ## Public exports (openchamber-routes.js)
798
798
  Browser completion checks use `appType=web&updateStatus=true` to stay on the
@@ -1,3 +1,5 @@
1
+ import { registerNotificationEmitRoutes } from '../notifications/emit-route.js';
2
+
1
3
  export const createBootstrapRuntime = (dependencies) => {
2
4
  const {
3
5
  createUiAuth,
@@ -63,7 +65,9 @@ export const createBootstrapRuntime = (dependencies) => {
63
65
  getCachedZenModels,
64
66
  setAutoAcceptSession,
65
67
  agentToolRuntime,
68
+ pluginNotificationEmitter,
66
69
  desktopUpdater,
70
+ skipBodyParsing,
67
71
  } = options;
68
72
 
69
73
  const uiAuthController = createUiAuth({
@@ -90,10 +94,17 @@ export const createBootstrapRuntime = (dependencies) => {
90
94
  uiAuthController,
91
95
  });
92
96
 
93
- registerCommonRequestMiddleware(app, { express, verboseRequestLogs });
97
+ registerCommonRequestMiddleware(app, { express, verboseRequestLogs, skipBodyParsing });
94
98
 
95
99
  registerAgentToolRoutes(app, { express, agentToolRuntime });
96
100
 
101
+ const notificationEmitRoutes = registerNotificationEmitRoutes(app, {
102
+ express,
103
+ isAgentToolRequestAuthorized: (req) => agentToolRuntime?.authorizeRequest?.(req) === true,
104
+ emitter: pluginNotificationEmitter,
105
+ });
106
+ notificationEmitRoutes.registerPluginRoute();
107
+
97
108
  registerAuthAndAccessRoutes(app, {
98
109
  express,
99
110
  tunnelAuthController,
@@ -110,6 +121,8 @@ export const createBootstrapRuntime = (dependencies) => {
110
121
  normalizeTunnelSessionTtlMs,
111
122
  });
112
123
 
124
+ notificationEmitRoutes.registerApiRoute();
125
+
113
126
  registerTtsRoutes(app, { sayTTSCapability });
114
127
 
115
128
  registerNotificationRoutes(app, {
@@ -63,13 +63,16 @@ export const requireOpenCodeV2 = async (launch, options) => {
63
63
 
64
64
  // External URLs have no local executable. A legacy probe identifies v1 only
65
65
  // from its JSON contract; neither HTML fallbacks nor auth failures imply v1.
66
+ // v1 has no `/api/info`: its catch-all serves the web UI, and a v1 build
67
+ // without the embedded UI proxies that path to app.opencode.ai, which can hang
68
+ // or fail. A failed v2 probe therefore still falls through to the v1 probe.
66
69
  export const readExternalOpenCodeVersion = async (baseUrl, headers, fetchImpl = fetch) => {
67
70
  const request = (pathname) => fetchImpl(new URL(pathname, baseUrl), {
68
71
  headers: { ...headers, Accept: 'application/json' }, redirect: 'error', signal: AbortSignal.timeout(5000),
69
72
  });
70
- const response = await request('/api/info');
71
- if (response.status === 401 || response.status === 403) return null;
72
- const info = await readOpenCodeInfo(response);
73
+ const response = await request('/api/info').catch(() => null);
74
+ if (response && (response.status === 401 || response.status === 403)) return null;
75
+ const info = response ? await readOpenCodeInfo(response) : null;
73
76
  if (info) return info.version;
74
77
  const legacy = await request('/global/health');
75
78
  if (!legacy.ok) return null;
@@ -30,6 +30,14 @@ describe('OpenCode compatibility', () => {
30
30
  expect(requests).toEqual(['/api/info', '/global/health']);
31
31
  });
32
32
 
33
+ it('still identifies v1 when its /api/info fallback hangs or fails', async () => {
34
+ const fetchImpl = async (url) => {
35
+ if (url.pathname === '/api/info') throw new DOMException('The operation timed out.', 'TimeoutError');
36
+ return Response.json({ healthy: true, version: '1.18.32' });
37
+ };
38
+ expect(await readExternalOpenCodeVersion('http://localhost:4096', {}, fetchImpl)).toBe('1.18.32');
39
+ });
40
+
33
41
  it.each([401, 403])('does not call an auth failure v1 (%s)', async (status) => {
34
42
  let requests = 0;
35
43
  expect(await readExternalOpenCodeVersion('http://localhost:4096', {}, async () => {
@@ -261,6 +261,33 @@ describe('entity modules speak OpenCode 2 shapes', () => {
261
261
  }
262
262
  });
263
263
 
264
+ it('lists servers from a global opencode.jsonc next to opencode.json and edits them in place', () => {
265
+ const configDir = path.join(process.env.XDG_CONFIG_HOME, 'opencode');
266
+ const jsonPath = path.join(configDir, 'opencode.json');
267
+ const jsoncPath = path.join(configDir, 'opencode.jsonc');
268
+ fs.mkdirSync(configDir, { recursive: true });
269
+ fs.writeFileSync(jsonPath, JSON.stringify({ autoupdate: true }, null, 2), 'utf8');
270
+ fs.writeFileSync(jsoncPath, [
271
+ '{',
272
+ ' // servers live only in the jsonc file',
273
+ ' "mcp": { "servers": { "docs": { "type": "remote", "url": "https://docs.example.com/mcp" } } }',
274
+ '}',
275
+ ].join('\n'), 'utf8');
276
+
277
+ try {
278
+ expect(listMcpConfigs(projectDir).map((entry) => entry.name)).toContain('docs');
279
+ expect(getMcpConfig('docs', projectDir)).toEqual(expect.objectContaining({ scope: 'user' }));
280
+
281
+ updateMcpConfig('docs', { disabled: true }, projectDir);
282
+
283
+ expect(getMcpConfig('docs', projectDir)).toEqual(expect.objectContaining({ disabled: true }));
284
+ expect(readJson(jsonPath)).toEqual({ autoupdate: true });
285
+ } finally {
286
+ fs.rmSync(jsonPath, { force: true });
287
+ fs.rmSync(jsoncPath, { force: true });
288
+ }
289
+ });
290
+
264
291
  it('reads a v1 mcp entry and rewrites it under mcp.servers in the same file', () => {
265
292
  const configPath = write('opencode.json', JSON.stringify({
266
293
  mcp: {
@@ -111,6 +111,18 @@ describe('OpenCode global config paths', () => {
111
111
  expect(response.json).toHaveBeenLastCalledWith({
112
112
  content: 'Global behavior', exists: true, path: path.join(process.env.XDG_CONFIG_HOME, 'opencode', 'AGENTS.md'),
113
113
  });
114
+
115
+ // A write based on an outdated copy is refused and leaves the file alone.
116
+ await handlers.get('PUT /api/behavior/agents-md')({
117
+ body: { content: 'Stale draft', expectedContent: 'Older behavior' },
118
+ }, response);
119
+ expect(response.status).toHaveBeenLastCalledWith(409);
120
+ expect(fs.readFileSync(path.join(process.env.XDG_CONFIG_HOME, 'opencode', 'AGENTS.md'), 'utf8')).toBe('Global behavior');
121
+
122
+ await handlers.get('PUT /api/behavior/agents-md')({
123
+ body: { content: 'Current draft', expectedContent: 'Global behavior' },
124
+ }, response);
125
+ expect(fs.readFileSync(path.join(process.env.XDG_CONFIG_HOME, 'opencode', 'AGENTS.md'), 'utf8')).toBe('Current draft');
114
126
  fs.rmSync(root, { recursive: true, force: true });
115
127
  });
116
128
  });
@@ -162,6 +162,7 @@ export function deleteMcpEntry(config: Record<string, unknown>, name: string): b
162
162
  export function toProviderPackage(value: unknown): string | undefined;
163
163
  export function toNpmPackage(value: unknown): string | undefined;
164
164
  export function toProviderEntity(raw: unknown): ProviderEntity;
165
+ export function readStoredProviderEntry(configs: unknown[], providerId: string): ProviderEntity | null;
165
166
 
166
167
  export function toPluginEntity(raw: unknown): PluginEntity | null;
167
168
  export function fromPluginEntity(entity: unknown): string | PluginEntity | null;
@@ -171,6 +172,7 @@ export function readPluginList(config: unknown): Array<{ entry: PluginEntity; ke
171
172
  export type WebSearchSelection = false | string | null;
172
173
  export function parseWebSearchSelection(value: unknown): WebSearchSelection | undefined;
173
174
  export function writeWebSearchSelection(config: Record<string, unknown>, selection: WebSearchSelection): boolean;
175
+ export function writeWarmingEnabled(config: Record<string, unknown>, enabled: boolean): boolean;
174
176
  export interface WebSearchConfigLayers {
175
177
  userConfig: object | null;
176
178
  projectConfig: object | null;
@@ -668,6 +668,28 @@ function toProviderEntity(raw) {
668
668
  ]);
669
669
  }
670
670
 
671
+ /**
672
+ * The provider entry an editor should start from: the winning config layer's
673
+ * stored entry (layers passed highest precedence first) in v2 shape, without
674
+ * the literal `settings.apiKey` secret. Returns null when no layer defines it.
675
+ * Unlike the live provider OpenCode serves, this carries `env` and only the
676
+ * reasoning levels the user wrote.
677
+ */
678
+ function readStoredProviderEntry(configs, providerId) {
679
+ for (const config of configs) {
680
+ const { value } = readSectionEntry(config, 'providers', providerId);
681
+ if (value === undefined) continue;
682
+ const entity = toProviderEntity(value);
683
+ if (entity.settings && 'apiKey' in entity.settings) {
684
+ const { apiKey: _secret, ...settings } = entity.settings;
685
+ if (Object.keys(settings).length) entity.settings = settings;
686
+ else delete entity.settings;
687
+ }
688
+ return entity;
689
+ }
690
+ return null;
691
+ }
692
+
671
693
  // ============== PLUGINS ==============
672
694
 
673
695
  const PLUGIN_SECTION = { v2: 'plugins', v1: 'plugin' };
@@ -742,6 +764,24 @@ function writeWebSearchSelection(config, selection) {
742
764
  return JSON.stringify(config.websearch) !== before;
743
765
  }
744
766
 
767
+ // ============== SESSION WARMING ==============
768
+
769
+ /**
770
+ * The `warming` key: `true` keeps idle sessions' prompt cache warm with
771
+ * OpenCode's defaults, an object tunes prompt/interval/duration. Turning it on
772
+ * keeps a hand-tuned object as is; turning it off removes the key (OpenCode's
773
+ * default is off). Returns whether the config changed.
774
+ */
775
+ function writeWarmingEnabled(config, enabled) {
776
+ const before = JSON.stringify(config.warming);
777
+ if (!enabled) {
778
+ delete config.warming;
779
+ } else if (typeof config.warming !== 'object' || config.warming === null) {
780
+ config.warming = true;
781
+ }
782
+ return JSON.stringify(config.warming) !== before;
783
+ }
784
+
745
785
  const hasWebSearchKey = (config) => config != null && Object.hasOwn(config, 'websearch');
746
786
 
747
787
  /**
@@ -792,10 +832,12 @@ export {
792
832
  toProviderPackage,
793
833
  toNpmPackage,
794
834
  toProviderEntity,
835
+ readStoredProviderEntry,
795
836
  toPluginEntity,
796
837
  fromPluginEntity,
797
838
  readPluginList,
798
839
  parseWebSearchSelection,
799
840
  writeWebSearchSelection,
800
841
  findWebSearchProjectOverride,
842
+ writeWarmingEnabled,
801
843
  };
@@ -9,6 +9,7 @@ import {
9
9
  parseModelSelection,
10
10
  formatModelSelection,
11
11
  toAgentEntity,
12
+ writeWarmingEnabled,
12
13
  fromAgentEntity,
13
14
  isLegacyAgentFrontmatter,
14
15
  toCommandEntity,
@@ -464,3 +465,19 @@ describe('findWebSearchProjectOverride', () => {
464
465
  expect(findWebSearchProjectOverride(layers(), [{ path: userPath, config: { websearch: false } }])).toBeNull();
465
466
  });
466
467
  });
468
+
469
+ describe('session warming', () => {
470
+ it('turns warming on and off, keeping a hand-tuned object', () => {
471
+ const config = { model: 'openai/gpt-5' };
472
+ expect(writeWarmingEnabled(config, true)).toBe(true);
473
+ expect(config.warming).toBe(true);
474
+ expect(writeWarmingEnabled(config, true)).toBe(false);
475
+ expect(writeWarmingEnabled(config, false)).toBe(true);
476
+ expect(config).toEqual({ model: 'openai/gpt-5' });
477
+ expect(writeWarmingEnabled(config, false)).toBe(false);
478
+
479
+ const tuned = { warming: { interval: '3 minutes' } };
480
+ expect(writeWarmingEnabled(tuned, true)).toBe(false);
481
+ expect(tuned.warming).toEqual({ interval: '3 minutes' });
482
+ });
483
+ });
@@ -94,6 +94,7 @@ export const registerServerStatusRoutes = (app, dependencies) => {
94
94
  'api.health.v1',
95
95
  'api.runtime-url.v1',
96
96
  'api.raw-file.v1',
97
+ 'api.notifications.emit.v1',
97
98
  'realtime.sse.v1',
98
99
  'realtime.websocket.global-events.v1',
99
100
  'terminal.websocket.v1',
@@ -1074,10 +1075,15 @@ export const registerSettingsUtilityRoutes = (app, dependencies) => {
1074
1075
  };
1075
1076
 
1076
1077
  export const registerCommonRequestMiddleware = (app, dependencies) => {
1077
- const { express, verboseRequestLogs = false } = dependencies;
1078
+ // `skipBodyParsing(req)` names a request whose body must reach its route untouched: a
1079
+ // request the isolated-spaces dispatcher streams into a space, where a parsed body would
1080
+ // otherwise be consumed here and lost.
1081
+ const { express, verboseRequestLogs = false, skipBodyParsing = () => false } = dependencies;
1078
1082
 
1079
1083
  app.use((req, res, next) => {
1080
- if (req.path === '/api/config/themes' || req.path.startsWith('/api/config/themes/')) {
1084
+ if (skipBodyParsing(req)) {
1085
+ next();
1086
+ } else if (req.path === '/api/config/themes' || req.path.startsWith('/api/config/themes/')) {
1081
1087
  express.json({ limit: '1mb' })(req, res, next);
1082
1088
  } else if (req.path.startsWith('/api/behavior')) {
1083
1089
  const contentLength = parseInt(req.headers['content-length'] || '0', 10);
@@ -1093,6 +1099,8 @@ export const registerCommonRequestMiddleware = (app, dependencies) => {
1093
1099
  req.path.startsWith('/api/config/settings') ||
1094
1100
  req.path.startsWith('/api/config/skills') ||
1095
1101
  req.path.startsWith('/api/config/plugins') ||
1102
+ req.path.startsWith('/api/config/websearch') ||
1103
+ req.path.startsWith('/api/config/warming') ||
1096
1104
  req.path.startsWith('/api/projects') ||
1097
1105
  req.path.startsWith('/api/fs') ||
1098
1106
  req.path.startsWith('/api/git') ||
@@ -1112,7 +1120,8 @@ export const registerCommonRequestMiddleware = (app, dependencies) => {
1112
1120
  req.path.startsWith('/api/text') ||
1113
1121
  req.path.startsWith('/api/voice') ||
1114
1122
  req.path.startsWith('/api/tts') ||
1115
- req.path.startsWith('/api/openchamber/tunnel')
1123
+ req.path.startsWith('/api/openchamber/tunnel') ||
1124
+ req.path.startsWith('/api/openchamber/spaces')
1116
1125
  ) {
1117
1126
  express.json({ limit: '50mb' })(req, res, next);
1118
1127
  } else if (req.path.startsWith('/api')) {
@@ -1122,7 +1131,14 @@ export const registerCommonRequestMiddleware = (app, dependencies) => {
1122
1131
  }
1123
1132
  });
1124
1133
 
1125
- app.use(express.urlencoded({ extended: true, limit: '50mb' }));
1134
+ const urlencoded = express.urlencoded({ extended: true, limit: '50mb' });
1135
+ app.use((req, res, next) => {
1136
+ if (skipBodyParsing(req)) {
1137
+ next();
1138
+ return;
1139
+ }
1140
+ urlencoded(req, res, next);
1141
+ });
1126
1142
 
1127
1143
  app.use((req, _res, next) => {
1128
1144
  if (verboseRequestLogs) {
@@ -127,6 +127,47 @@ describe('core-routes', () => {
127
127
  expect(response.body).toEqual({ body: { content: 'Snippet body' } });
128
128
  });
129
129
 
130
+ it('leaves the body of a request alone when told to, for both parsers', async () => {
131
+ const app = express();
132
+ registerCommonRequestMiddleware(app, { express, skipBodyParsing: (req) => req.path.startsWith('/api/spaces/') });
133
+ const raw = (req, res) => {
134
+ // A body a parser read is complete already, and its stream ends no second time.
135
+ if (req.complete) {
136
+ res.json({ body: req.body ?? null, raw: '' });
137
+ return;
138
+ }
139
+ const chunks = [];
140
+ req.on('data', (chunk) => chunks.push(chunk));
141
+ req.on('end', () => res.json({ body: req.body ?? null, raw: Buffer.concat(chunks).toString('utf8') }));
142
+ };
143
+ app.post('/api/spaces/abc/form', raw);
144
+ app.post('/api/spaces/abc/config/snippets/x', raw);
145
+ app.post('/api/other/form', raw);
146
+
147
+ const kept = await request(app).post('/api/spaces/abc/form').type('form').send('a=1&b=2').expect(200);
148
+ expect(kept.body).toEqual({ body: null, raw: 'a=1&b=2' });
149
+ const keptJson = await request(app).post('/api/spaces/abc/config/snippets/x').send({ content: 'x' }).expect(200);
150
+ expect(keptJson.body).toEqual({ body: null, raw: '{"content":"x"}' });
151
+ // Elsewhere the parsers still run, and a parsed body leaves nothing to read.
152
+ const parsed = await request(app).post('/api/other/form').type('form').send('a=1&b=2').expect(200);
153
+ expect(parsed.body).toEqual({ body: { a: '1', b: '2' }, raw: '' });
154
+ });
155
+
156
+ it('should parse JSON bodies for the web search config route', async () => {
157
+ const app = express();
158
+ registerCommonRequestMiddleware(app, { express });
159
+ app.put('/api/config/websearch', (req, res) => {
160
+ res.json({ body: req.body });
161
+ });
162
+
163
+ const response = await request(app)
164
+ .put('/api/config/websearch')
165
+ .send({ selection: 'random' })
166
+ .expect(200);
167
+
168
+ expect(response.body).toEqual({ body: { selection: 'random' } });
169
+ });
170
+
130
171
  it('should parse JSON bodies for custom provider upsert routes', async () => {
131
172
  const app = express();
132
173
  registerCommonRequestMiddleware(app, { express });
@@ -698,15 +698,8 @@ export const createOpenCodeEnvRuntime = (deps) => {
698
698
  ];
699
699
 
700
700
  const getWindowsNativeOpencodePackageNames = () => {
701
- // TEMPORARY WORKAROUND — Windows ARM64: native opencode.exe fails with a Bun
702
- // FFI/TinyCC dlopen error (https://github.com/anomalyco/opencode/issues/19130).
703
- // prepare-opencode-cli.mjs bundles x64-baseline instead; match that here so
704
- // the runtime resolver looks for the same x64-baseline package. Restore the
705
- // arm64 branch below when the upstream issue is resolved.
706
701
  if (process.arch === 'arm64') {
707
- // --- ORIGINAL (restore when ARM64 is fixed) ---
708
- // return ['@opencode/cli-windows-arm64', 'opencode-windows-arm64'];
709
- return WINDOWS_X64_NATIVE_PACKAGES;
702
+ return [path.join('@opencode', 'cli-windows-arm64'), 'opencode-windows-arm64'];
710
703
  }
711
704
  if (process.arch === 'x64') {
712
705
  // Prefer the baseline build when bypassing package-manager wrappers so the
@@ -103,6 +103,7 @@ export const createFeatureRoutesRuntime = (dependencies) => {
103
103
  createFsSearchRuntime,
104
104
  openchamberDataDir,
105
105
  onGuestDeactivated,
106
+ surfaceViewerHeaders,
106
107
  openchamberUserConfigRoot,
107
108
  managedChatsRoot,
108
109
  normalizeDirectoryPath,
@@ -331,7 +332,7 @@ export const createFeatureRoutesRuntime = (dependencies) => {
331
332
  registerGitHubRoutes(app);
332
333
  registerLinearRoutes(app);
333
334
  await registerBuiltInGuests({ persistPath: extensionsPersistPath(openchamberDataDir), root: routeDependencies.builtInExtensionsDir });
334
- registerGuestRoutes(app, { openchamberDataDir, openchamberVersion, resolveGitBinaryForSpawn, resolveOptionalProjectDirectory, getSmallModelService, onGuestDeactivated });
335
+ registerGuestRoutes(app, { openchamberDataDir, openchamberVersion, resolveGitBinaryForSpawn, resolveOptionalProjectDirectory, getSmallModelService, onGuestDeactivated, surfaceViewerHeaders });
335
336
  registerGitRoutes(app, {
336
337
  emitWorktreeChanged: ({ directories, at }) => {
337
338
  const clients = getOpenChamberEventClients();