@vantran-se/openchamber-web 2.0.4 → 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 (243) hide show
  1. package/dist/assets/{CodeMirrorCommentWidgets-DZF8ntaq.js → CodeMirrorCommentWidgets-DCAQTLMM.js} +1 -1
  2. package/dist/assets/DiffView-uzNpHBlV.js +4 -0
  3. package/dist/assets/FilesView-Bkq0sjNx.js +17 -0
  4. package/dist/assets/{GitView-DwQwqDvu.js → GitView-BmibZC7n.js} +4 -4
  5. package/dist/assets/{GuestAttachDialog-CoXM0gh_.js → GuestAttachDialog-D7TJG1rt.js} +1 -1
  6. package/dist/assets/GuestSurfacePane-PAcYaFzP.js +1 -0
  7. package/dist/assets/{JsonTreeView-Cq_27v0y.js → JsonTreeView-BNT2yUVu.js} +1 -1
  8. package/dist/assets/{LinearIssuesView-CsSSRMhn.js → LinearIssuesView-D3Azksju.js} +1 -1
  9. package/dist/assets/MarkdownImageGallery-BGY6tuiK.js +1 -0
  10. package/dist/assets/MarkdownRendererImpl-DJmhQ37f.js +12 -0
  11. package/dist/assets/{OnboardingScreen-ClatMQAX.js → OnboardingScreen-D_41H2qC.js} +1 -1
  12. package/dist/assets/{PatchDiff-DOwlrIK9.js → PatchDiff-C0RSqa9W.js} +1 -1
  13. package/dist/assets/{PierreDiffViewer-CrcGqqD_.js → PierreDiffViewer-BBE7tTY2.js} +3 -3
  14. package/dist/assets/{PlanView-BGLsMgQk.js → PlanView-Bqc-xuDe.js} +4 -4
  15. package/dist/assets/PluginPane-DQ0k41Q-.js +3 -0
  16. package/dist/assets/SettingsView-BAiJU5hJ.js +30 -0
  17. package/dist/assets/{SettingsWindow-B0BAy-f9.js → SettingsWindow-DuC4ecLw.js} +1 -1
  18. package/dist/assets/{SubmoduleDiffSummary-fcdDzCSW.js → SubmoduleDiffSummary-0HgmcMXz.js} +1 -1
  19. package/dist/assets/{ToolOutputDialog-B7xaUz2H.js → ToolOutputDialog-DdHkQJEE.js} +6 -6
  20. package/dist/assets/{ToolPartDiffPreview-CkWCaUmZ.js → ToolPartDiffPreview-DWdN0r5K.js} +2 -2
  21. package/dist/assets/{VirtualizedFileDiff-DONC4eI2.js → VirtualizedFileDiff-ClDezf2d.js} +1 -1
  22. package/dist/assets/{WalkthroughView-CXDImOXW.js → WalkthroughView-BJicp0RC.js} +2 -2
  23. package/dist/assets/{appThemeRegistry-Br2TLSlb.js → appThemeRegistry-CiuyzfSW.js} +1 -1
  24. package/dist/assets/changesTree-CePHbFcj.js +1 -0
  25. package/dist/assets/commitAndPush-DTKb6IwT.js +1 -0
  26. package/dist/assets/{computeVirtualFileMetrics-_-20umCs.js → computeVirtualFileMetrics-kM_zJNMt.js} +2 -2
  27. package/dist/assets/de-BMnfXm-G.js +10 -0
  28. package/dist/assets/es-DKEvCCL6.js +16 -0
  29. package/dist/assets/fr-D9fgE2xE.js +14 -0
  30. package/dist/assets/index-DUke1nme.css +1 -0
  31. package/dist/assets/{index-OKDYjcMF.js → index-DWB8bR_g.js} +1 -1
  32. package/dist/assets/index-Di6zUe5h.js +41 -0
  33. package/dist/assets/{isDiffPlainText-D77M6q1f.js → isDiffPlainText-DOoIsGqs.js} +1 -1
  34. package/dist/assets/{isFilePlainText-C54B4Mns.js → isFilePlainText-bt5VHvcd.js} +1 -1
  35. package/dist/assets/ja-BEXad-KW.js +16 -0
  36. package/dist/assets/ko-DGrQ0lEe.js +16 -0
  37. package/dist/assets/{main-CGEvLMd2.js → main-BpLQbZC2.js} +2 -2
  38. package/dist/assets/main-CI9DtHJv.js +896 -0
  39. package/dist/assets/{markdown-shiki.worker-CahLvHIq.js → markdown-shiki.worker-Bg8SoAJJ.js} +8 -5
  40. package/dist/assets/markdownCore-DK2KK5RW.js +7 -0
  41. package/dist/assets/{miniChat-BSxkRQLj.js → miniChat-CIWaR8wG.js} +2 -2
  42. package/dist/assets/{mobile-C5KRTNOB.js → mobile-CnWKEYIP.js} +2 -2
  43. package/dist/assets/patchFileDiff-Cqn0C0az.js +13 -0
  44. package/dist/assets/pl-BlhkmR5b.js +16 -0
  45. package/dist/assets/pt-BR-Bs96Ew9I.js +16 -0
  46. package/dist/assets/{renderElectronMiniChatApp-CnkAQV6o.js → renderElectronMiniChatApp-D9t0-9nV.js} +2 -2
  47. package/dist/assets/renderMobileApp-CW7fVs_s.js +25 -0
  48. package/dist/assets/runtimeEndpointReset-D0PcCb3K.js +27 -0
  49. package/dist/assets/scheduledTasksApi-Dahv3ws0.js +1 -0
  50. package/dist/assets/{shikiHighlight-4O0ZM14D.js → shikiHighlight-l-MbaNS4.js} +1 -1
  51. package/dist/assets/{textMateThemeFromAppTheme-yNq7b0DZ.js → textMateThemeFromAppTheme-C-kGcOpD.js} +1 -1
  52. package/dist/assets/tr-CehUK1M9.js +7 -0
  53. package/dist/assets/uk-Bno5WGNz.js +16 -0
  54. package/dist/assets/{useAppFontEffects-Bzyszv2N.js → useAppFontEffects-DDjuwVqi.js} +160 -156
  55. package/dist/assets/{useInlineCommentController-zOf6ORAq.js → useInlineCommentController-Clg8EO3A.js} +2 -2
  56. package/dist/assets/{useRootScrollLock-DCaZ4BWi.js → useRootScrollLock-nDcRrgJ1.js} +2 -2
  57. package/dist/assets/{vendor-codemirror-language-data-6cEFPNUR.js → vendor-codemirror-language-data-oKF0MAwd.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-Bc1WPd4i.js} +1 -1
  61. package/dist/assets/zh-CN-DnAweIWo.js +16 -0
  62. package/dist/assets/zh-TW-BbBBK25p.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 +3 -3
  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
@@ -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)
@@ -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();
@@ -1,4 +1,4 @@
1
- import { readOpenCodeInfo, isSupportedOpenCodeVersion, requireOpenCodeV2, UnsupportedOpenCodeVersionError } from './compatibility.js';
1
+ import { readOpenCodeInfo, readExternalOpenCodeVersion, isSupportedOpenCodeVersion, requireOpenCodeV2, UnsupportedOpenCodeVersionError } from './compatibility.js';
2
2
  import { spawn, spawnSync } from 'node:child_process';
3
3
  import net from 'node:net';
4
4
  import { stripAppImageArgv0Leak } from '../inherited-env.js';
@@ -803,6 +803,16 @@ export const createOpenCodeLifecycleRuntime = (deps) => {
803
803
  }
804
804
  };
805
805
 
806
+ // The version of an explicitly configured server that answered but is not a
807
+ // supported OpenCode (v1, or 2.x below the minimum); null for anything else,
808
+ // including a server that is down.
809
+ const readUnsupportedExternalOpenCodeVersion = async (port, origin) => {
810
+ if (!port || port <= 0) return null;
811
+ const version = await readExternalOpenCodeVersion(origin ?? `http://127.0.0.1:${port}`, getOpenCodeAuthHeaders())
812
+ .catch(() => null);
813
+ return version && !isSupportedOpenCodeVersion(version) ? version : null;
814
+ };
815
+
806
816
  const waitForOpenCodePort = async (timeoutMs = 15000) => {
807
817
  if (state.openCodePort !== null) {
808
818
  return state.openCodePort;
@@ -1117,7 +1127,7 @@ export const createOpenCodeLifecycleRuntime = (deps) => {
1117
1127
  }
1118
1128
 
1119
1129
  const info = await readOpenCodeInfo(response);
1120
- if (!info) throw new Error('OpenCode did not return valid version information.');
1130
+ if (!info) throw new Error('The server did not identify itself as OpenCode 2.x. OpenChamber requires OpenCode 2.x; if the server runs an older OpenCode, upgrade it.');
1121
1131
  if (!isSupportedOpenCodeVersion(info.version)) throw new UnsupportedOpenCodeVersionError(info.version);
1122
1132
  state.isOpenCodeReady = true;
1123
1133
  state.lastOpenCodeError = null;
@@ -1216,6 +1226,7 @@ export const createOpenCodeLifecycleRuntime = (deps) => {
1216
1226
  const bootstrapOpenCodeAtStartup = async () => {
1217
1227
  const bootstrapStartedAt = performance.now();
1218
1228
  let bootstrapError = null;
1229
+ let unsupportedExternalVersion = null;
1219
1230
  recordStartupPerformance('opencode.bootstrap.start');
1220
1231
  try {
1221
1232
  // Before doing anything, reap any OpenCode process WE spawned in a prior
@@ -1256,6 +1267,20 @@ export const createOpenCodeLifecycleRuntime = (deps) => {
1256
1267
  state.lastOpenCodeError = null;
1257
1268
  state.openCodeNotReadySince = 0;
1258
1269
  syncToHmrState();
1270
+ } else if (env.ENV_EFFECTIVE_PORT && (unsupportedExternalVersion = await readUnsupportedExternalOpenCodeVersion(
1271
+ env.ENV_EFFECTIVE_PORT,
1272
+ env.ENV_CONFIGURED_OPENCODE_HOST?.origin,
1273
+ ))) {
1274
+ // The configured server is an OpenCode this OpenChamber cannot use.
1275
+ // Attach to it anyway, not ready, so the compatibility check reports
1276
+ // its version instead of a managed instance silently replacing it.
1277
+ const label = env.ENV_CONFIGURED_OPENCODE_HOST ? env.ENV_CONFIGURED_OPENCODE_HOST.origin : `http://localhost:${env.ENV_EFFECTIVE_PORT}`;
1278
+ console.warn(`OpenCode ${unsupportedExternalVersion} at ${label} is not supported by this OpenChamber`);
1279
+ state.openCodeBaseUrl = env.ENV_CONFIGURED_OPENCODE_HOST?.origin ?? null;
1280
+ setOpenCodePort(env.ENV_EFFECTIVE_PORT);
1281
+ state.isExternalOpenCode = true;
1282
+ syncToHmrState();
1283
+ throw new UnsupportedOpenCodeVersionError(unsupportedExternalVersion);
1259
1284
  } else {
1260
1285
  // We never auto-attach to an arbitrary pre-existing OpenCode instance.
1261
1286
  // Attaching to an external server requires explicit opt-in via env
@@ -1282,6 +1307,9 @@ export const createOpenCodeLifecycleRuntime = (deps) => {
1282
1307
  await waitForOpenCodeReady();
1283
1308
  } catch (error) {
1284
1309
  bootstrapError = error;
1310
+ // Skip-start mode assumed readiness up front; a server that never
1311
+ // proved itself must not keep reporting ready to startup diagnostics.
1312
+ state.isOpenCodeReady = false;
1285
1313
  console.error(`OpenCode readiness check failed: ${error.message}`);
1286
1314
  }
1287
1315
  } catch (error) {
@@ -467,6 +467,33 @@ describe('OpenCode lifecycle', () => {
467
467
  expect(runtime.testState.openCodeBaseUrl).toBe('http://seamus:4095');
468
468
  });
469
469
 
470
+ it('attaches to an OPENCODE_HOST running OpenCode v1 instead of starting a managed server', async () => {
471
+ const requested = [];
472
+ globalThis.fetch = vi.fn(async (url) => {
473
+ const href = String(url);
474
+ requested.push(href);
475
+ if (href.endsWith('/global/health')) return Response.json({ healthy: true, version: '1.18.32' });
476
+ return new Response('<html>OpenCode</html>', { headers: { 'content-type': 'text/html' } });
477
+ });
478
+ const runtime = createRuntime({
479
+ reapManagedOrphanedProcesses: vi.fn(async () => ({ reaped: 0 })),
480
+ }, {}, {
481
+ ENV_CONFIGURED_OPENCODE_PORT: null,
482
+ ENV_CONFIGURED_OPENCODE_HOST: { origin: 'http://seamus:4095', port: 4095 },
483
+ ENV_EFFECTIVE_PORT: 4095,
484
+ });
485
+
486
+ await runtime.bootstrapOpenCodeAtStartup();
487
+
488
+ expect(spawnMock).not.toHaveBeenCalled();
489
+ expect(requested).toContain('http://seamus:4095/global/health');
490
+ expect(runtime.testState.isExternalOpenCode).toBe(true);
491
+ expect(runtime.testState.isOpenCodeReady).toBe(false);
492
+ expect(runtime.testState.openCodeBaseUrl).toBe('http://seamus:4095');
493
+ expect(runtime.testState.openCodePort).toBe(4095);
494
+ expect(runtime.testState.lastOpenCodeError).toContain('1.18.32');
495
+ });
496
+
470
497
  it('warms recently used directories after a successful bootstrap', async () => {
471
498
  const fetchMock = vi.fn(async () => ({
472
499
  ok: true,