@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
@@ -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';
@@ -55,6 +55,132 @@ const WARMUP_REQUEST_TIMEOUT_MS = 30000;
55
55
  const MANAGED_STDERR_TAIL_MAX_BYTES = 32 * 1024;
56
56
  const HEALTH_FAILURE_DETAIL_MAX_LENGTH = 256;
57
57
 
58
+ export const resolveOpenCodeConnectionKind = ({ runtime, configuredHost, lifecycleMode }) => {
59
+ if (configuredHost) return 'explicit-external';
60
+ if (lifecycleMode === 'managed' || runtime === 'desktop') return 'managed-owned';
61
+ return 'shared-local';
62
+ };
63
+
64
+ export const createOpenCodeServerComposition = ({
65
+ runtime,
66
+ configuredHost,
67
+ lifecycleMode,
68
+ createSharedRuntime,
69
+ createManagedPluginRuntime = () => null,
70
+ getManagedBaseUrl = () => null,
71
+ getManagedAuthHeaders = () => ({}),
72
+ }) => {
73
+ const kind = resolveOpenCodeConnectionKind({ runtime, configuredHost, lifecycleMode });
74
+ if (kind === 'shared-local' && !createSharedRuntime) {
75
+ throw new Error('Shared OpenCode composition requires a shared runtime factory');
76
+ }
77
+ const sharedRuntime = kind === 'shared-local' ? createSharedRuntime() : null;
78
+ const managedPluginRuntime = kind === 'managed-owned' ? createManagedPluginRuntime() : null;
79
+ const connection = createOpenCodeConnectionAdapter({
80
+ kind,
81
+ sharedRuntime,
82
+ getManagedBaseUrl,
83
+ getManagedAuthHeaders,
84
+ });
85
+ return { kind, sharedRuntime, managedPluginRuntime, connection };
86
+ };
87
+
88
+ export const createOpenCodeRecoveryCallbacks = ({
89
+ resetOpenCodeRuntimeProviders,
90
+ rebindUpstream,
91
+ interruptBusySessionsAfterRestart,
92
+ broadcastUiNotification,
93
+ }) => ({
94
+ onManagedRestarted() {
95
+ resetOpenCodeRuntimeProviders();
96
+ try {
97
+ rebindUpstream();
98
+ } catch (error) {
99
+ console.warn('Failed to rebind message stream after OpenCode restart:', error?.message ?? error);
100
+ }
101
+ try {
102
+ const { sessionIds } = interruptBusySessionsAfterRestart();
103
+ if (sessionIds.length === 0) return;
104
+ const multiple = sessionIds.length > 1;
105
+ broadcastUiNotification({
106
+ title: multiple ? 'Chats interrupted' : 'Chat interrupted',
107
+ body: multiple
108
+ ? 'OpenCode restarted during running responses. Send a message in each chat to continue.'
109
+ : 'OpenCode restarted during a running response. Send a message to continue.',
110
+ tag: 'opencode-restart-interrupted',
111
+ kind: 'opencode-restart-interrupted',
112
+ sessionId: sessionIds[0],
113
+ });
114
+ } catch (error) {
115
+ console.warn('Failed to reconcile sessions after OpenCode restart:', error?.message ?? error);
116
+ }
117
+ },
118
+ onSharedRecovered() {
119
+ resetOpenCodeRuntimeProviders();
120
+ rebindUpstream();
121
+ },
122
+ });
123
+
124
+ export const createOpenCodeLifecycleControls = ({
125
+ getKind,
126
+ restartLifecycle,
127
+ triggerLifecycleHealthCheck,
128
+ recoverShared,
129
+ onSharedRecovered,
130
+ }) => {
131
+ let sharedRecoveryPromise = null;
132
+ const recover = () => {
133
+ if (!recoverShared || !onSharedRecovered) {
134
+ return Promise.reject(new Error('Shared OpenCode recovery is not configured'));
135
+ }
136
+ if (!sharedRecoveryPromise) {
137
+ sharedRecoveryPromise = Promise.resolve()
138
+ .then(() => recoverShared())
139
+ .then(() => onSharedRecovered())
140
+ .finally(() => {
141
+ sharedRecoveryPromise = null;
142
+ });
143
+ }
144
+ return sharedRecoveryPromise;
145
+ };
146
+
147
+ return {
148
+ restart: (...args) => (
149
+ getKind() === 'shared-local' ? recover() : restartLifecycle(...args)
150
+ ),
151
+ triggerHealthCheck: (...args) => {
152
+ if (getKind() !== 'shared-local') {
153
+ return triggerLifecycleHealthCheck(...args);
154
+ }
155
+ return recover().catch((error) => {
156
+ console.warn('Failed to recover shared OpenCode service:', error?.message ?? error);
157
+ });
158
+ },
159
+ };
160
+ };
161
+
162
+ export const createOpenCodeConnectionAdapter = ({
163
+ kind,
164
+ sharedRuntime = null,
165
+ getManagedBaseUrl = () => null,
166
+ getManagedAuthHeaders = () => ({}),
167
+ }) => {
168
+ if (kind === 'shared-local' && !sharedRuntime) {
169
+ throw new Error('Shared OpenCode connection requires a shared service runtime');
170
+ }
171
+
172
+ return {
173
+ getOpenCodeConnectionKind: () => kind,
174
+ getOpenCodeBaseUrl: () => (
175
+ kind === 'shared-local' ? sharedRuntime.getBaseUrl() : getManagedBaseUrl()
176
+ ),
177
+ getOpenCodeAuthHeaders: () => (
178
+ kind === 'shared-local' ? sharedRuntime.getHeaders() : getManagedAuthHeaders()
179
+ ),
180
+ ownsOpenCodeProcess: () => kind === 'managed-owned',
181
+ };
182
+ };
183
+
58
184
  const getBoundedTextTail = (value, maxBytes) => {
59
185
  const buffer = Buffer.from(String(value ?? ''));
60
186
  if (buffer.byteLength <= maxBytes) return buffer.toString();
@@ -677,6 +803,16 @@ export const createOpenCodeLifecycleRuntime = (deps) => {
677
803
  }
678
804
  };
679
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
+
680
816
  const waitForOpenCodePort = async (timeoutMs = 15000) => {
681
817
  if (state.openCodePort !== null) {
682
818
  return state.openCodePort;
@@ -991,7 +1127,7 @@ export const createOpenCodeLifecycleRuntime = (deps) => {
991
1127
  }
992
1128
 
993
1129
  const info = await readOpenCodeInfo(response);
994
- 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.');
995
1131
  if (!isSupportedOpenCodeVersion(info.version)) throw new UnsupportedOpenCodeVersionError(info.version);
996
1132
  state.isOpenCodeReady = true;
997
1133
  state.lastOpenCodeError = null;
@@ -1090,6 +1226,7 @@ export const createOpenCodeLifecycleRuntime = (deps) => {
1090
1226
  const bootstrapOpenCodeAtStartup = async () => {
1091
1227
  const bootstrapStartedAt = performance.now();
1092
1228
  let bootstrapError = null;
1229
+ let unsupportedExternalVersion = null;
1093
1230
  recordStartupPerformance('opencode.bootstrap.start');
1094
1231
  try {
1095
1232
  // Before doing anything, reap any OpenCode process WE spawned in a prior
@@ -1130,6 +1267,20 @@ export const createOpenCodeLifecycleRuntime = (deps) => {
1130
1267
  state.lastOpenCodeError = null;
1131
1268
  state.openCodeNotReadySince = 0;
1132
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);
1133
1284
  } else {
1134
1285
  // We never auto-attach to an arbitrary pre-existing OpenCode instance.
1135
1286
  // Attaching to an external server requires explicit opt-in via env
@@ -1156,6 +1307,9 @@ export const createOpenCodeLifecycleRuntime = (deps) => {
1156
1307
  await waitForOpenCodeReady();
1157
1308
  } catch (error) {
1158
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;
1159
1313
  console.error(`OpenCode readiness check failed: ${error.message}`);
1160
1314
  }
1161
1315
  } catch (error) {
@@ -18,7 +18,14 @@ vi.mock('./startup-performance.js', () => ({
18
18
  recordStartupPerformance: recordStartupPerformanceMock,
19
19
  }));
20
20
 
21
- const { createOpenCodeLifecycleRuntime } = await import('./lifecycle.js');
21
+ const {
22
+ createOpenCodeConnectionAdapter,
23
+ createOpenCodeLifecycleControls,
24
+ createOpenCodeLifecycleRuntime,
25
+ createOpenCodeRecoveryCallbacks,
26
+ createOpenCodeServerComposition,
27
+ resolveOpenCodeConnectionKind,
28
+ } = await import('./lifecycle.js');
22
29
 
23
30
  const originalOpencodeBinary = process.env.OPENCODE_BINARY;
24
31
  const originalPath = process.env.PATH;
@@ -128,6 +135,226 @@ const createRuntime = (overrides = {}, stateOverrides = {}, envOverrides = {}) =
128
135
  return runtime;
129
136
  };
130
137
 
138
+ describe('OpenCode connection composition', () => {
139
+ it('selects shared-local for Web without an explicit host', () => {
140
+ expect(resolveOpenCodeConnectionKind({ runtime: 'web', configuredHost: null })).toBe('shared-local');
141
+ });
142
+
143
+ it('selects explicit-external when a host is configured', () => {
144
+ expect(resolveOpenCodeConnectionKind({
145
+ runtime: 'web',
146
+ configuredHost: { origin: 'https://opencode.example.com', port: 443 },
147
+ })).toBe('explicit-external');
148
+ });
149
+
150
+ it('keeps Desktop on the managed-owned lifecycle', () => {
151
+ expect(resolveOpenCodeConnectionKind({ runtime: 'desktop', configuredHost: null })).toBe('managed-owned');
152
+ });
153
+
154
+ it('gives an explicit host priority over the Desktop managed lifecycle', () => {
155
+ expect(resolveOpenCodeConnectionKind({
156
+ runtime: 'desktop',
157
+ configuredHost: { origin: 'https://opencode.example.com', port: 443 },
158
+ })).toBe('explicit-external');
159
+ });
160
+
161
+ it('keeps an embedded managed override on the managed-owned lifecycle', () => {
162
+ expect(resolveOpenCodeConnectionKind({
163
+ runtime: 'web',
164
+ configuredHost: null,
165
+ lifecycleMode: 'managed',
166
+ })).toBe('managed-owned');
167
+ });
168
+
169
+ it('requires a shared runtime factory for shared-local composition', () => {
170
+ expect(() => createOpenCodeServerComposition({
171
+ runtime: 'web',
172
+ configuredHost: null,
173
+ })).toThrow('Shared OpenCode composition requires a shared runtime factory');
174
+ });
175
+
176
+ it('creates shared and managed-only runtimes only for their owning connection kind', () => {
177
+ const sharedRuntime = { getBaseUrl: () => null, getHeaders: () => ({}) };
178
+ const managedPluginRuntime = { buildManagedChildEnv: () => ({}) };
179
+ const createSharedRuntime = vi.fn(() => sharedRuntime);
180
+ const createManagedPluginRuntime = vi.fn(() => managedPluginRuntime);
181
+
182
+ const shared = createOpenCodeServerComposition({
183
+ runtime: 'web',
184
+ configuredHost: null,
185
+ createSharedRuntime,
186
+ createManagedPluginRuntime,
187
+ });
188
+ const external = createOpenCodeServerComposition({
189
+ runtime: 'web',
190
+ configuredHost: { origin: 'https://opencode.example.com', port: 443 },
191
+ createSharedRuntime,
192
+ createManagedPluginRuntime,
193
+ });
194
+ const desktop = createOpenCodeServerComposition({
195
+ runtime: 'desktop',
196
+ configuredHost: null,
197
+ createSharedRuntime,
198
+ createManagedPluginRuntime,
199
+ });
200
+
201
+ expect(shared.sharedRuntime).toBe(sharedRuntime);
202
+ expect(shared.managedPluginRuntime).toBeNull();
203
+ expect(external.sharedRuntime).toBeNull();
204
+ expect(external.managedPluginRuntime).toBeNull();
205
+ expect(desktop.sharedRuntime).toBeNull();
206
+ expect(desktop.managedPluginRuntime).toBe(managedPluginRuntime);
207
+ expect(createSharedRuntime).toHaveBeenCalledTimes(1);
208
+ expect(createManagedPluginRuntime).toHaveBeenCalledTimes(1);
209
+ });
210
+
211
+ it('recovers the shared service through the production callback without interrupting sessions', async () => {
212
+ const restartManaged = vi.fn(async () => {});
213
+ const triggerManagedHealthCheck = vi.fn(async () => {});
214
+ const recoverShared = vi.fn(async () => {});
215
+ const resetOpenCodeRuntimeProviders = vi.fn();
216
+ const rebindUpstream = vi.fn();
217
+ const interruptBusySessionsAfterRestart = vi.fn(() => ({ sessionIds: [] }));
218
+ const broadcastUiNotification = vi.fn();
219
+ const callbacks = createOpenCodeRecoveryCallbacks({
220
+ resetOpenCodeRuntimeProviders,
221
+ rebindUpstream,
222
+ interruptBusySessionsAfterRestart,
223
+ broadcastUiNotification,
224
+ });
225
+ const shared = createOpenCodeLifecycleControls({
226
+ getKind: () => 'shared-local',
227
+ restartLifecycle: restartManaged,
228
+ triggerLifecycleHealthCheck: triggerManagedHealthCheck,
229
+ recoverShared,
230
+ onSharedRecovered: callbacks.onSharedRecovered,
231
+ });
232
+
233
+ await shared.restart('manual');
234
+
235
+ expect(recoverShared).toHaveBeenCalledOnce();
236
+ expect(resetOpenCodeRuntimeProviders).toHaveBeenCalledOnce();
237
+ expect(rebindUpstream).toHaveBeenCalledOnce();
238
+ expect(recoverShared.mock.invocationCallOrder[0]).toBeLessThan(rebindUpstream.mock.invocationCallOrder[0]);
239
+ expect(interruptBusySessionsAfterRestart).not.toHaveBeenCalled();
240
+ expect(broadcastUiNotification).not.toHaveBeenCalled();
241
+ expect(restartManaged).not.toHaveBeenCalled();
242
+ expect(triggerManagedHealthCheck).not.toHaveBeenCalled();
243
+ });
244
+
245
+ it('keeps managed restart interruption behavior in the production callback', () => {
246
+ const resetOpenCodeRuntimeProviders = vi.fn();
247
+ const rebindUpstream = vi.fn();
248
+ const interruptBusySessionsAfterRestart = vi.fn(() => ({ sessionIds: ['session-1'] }));
249
+ const broadcastUiNotification = vi.fn();
250
+ const callbacks = createOpenCodeRecoveryCallbacks({
251
+ resetOpenCodeRuntimeProviders,
252
+ rebindUpstream,
253
+ interruptBusySessionsAfterRestart,
254
+ broadcastUiNotification,
255
+ });
256
+
257
+ callbacks.onManagedRestarted();
258
+
259
+ expect(resetOpenCodeRuntimeProviders).toHaveBeenCalledOnce();
260
+ expect(rebindUpstream).toHaveBeenCalledOnce();
261
+ expect(interruptBusySessionsAfterRestart).toHaveBeenCalledOnce();
262
+ expect(broadcastUiNotification).toHaveBeenCalledWith(expect.objectContaining({
263
+ kind: 'opencode-restart-interrupted',
264
+ sessionId: 'session-1',
265
+ }));
266
+ });
267
+
268
+ it('shares concurrent shared recovery attempts', async () => {
269
+ let finishRecovery;
270
+ const recoverShared = vi.fn(() => new Promise((resolve) => {
271
+ finishRecovery = resolve;
272
+ }));
273
+ const onSharedRecovered = vi.fn(async () => {});
274
+ const shared = createOpenCodeLifecycleControls({
275
+ getKind: () => 'shared-local',
276
+ restartLifecycle: vi.fn(),
277
+ triggerLifecycleHealthCheck: vi.fn(),
278
+ recoverShared,
279
+ onSharedRecovered,
280
+ });
281
+
282
+ const first = shared.triggerHealthCheck();
283
+ const second = shared.triggerHealthCheck();
284
+ await vi.waitFor(() => expect(recoverShared).toHaveBeenCalledOnce());
285
+ finishRecovery();
286
+ await Promise.all([first, second]);
287
+
288
+ expect(recoverShared).toHaveBeenCalledOnce();
289
+ expect(onSharedRecovered).toHaveBeenCalledOnce();
290
+ });
291
+
292
+ it('contains shared health recovery failures and permits a later retry', async () => {
293
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
294
+ const recoverShared = vi.fn()
295
+ .mockRejectedValueOnce(new Error('service unavailable'))
296
+ .mockResolvedValueOnce(undefined);
297
+ const onSharedRecovered = vi.fn(async () => {});
298
+ const shared = createOpenCodeLifecycleControls({
299
+ getKind: () => 'shared-local',
300
+ restartLifecycle: vi.fn(),
301
+ triggerLifecycleHealthCheck: vi.fn(),
302
+ recoverShared,
303
+ onSharedRecovered,
304
+ });
305
+
306
+ await expect(shared.triggerHealthCheck()).resolves.toBeUndefined();
307
+ await expect(shared.restart()).resolves.toBeUndefined();
308
+
309
+ expect(recoverShared).toHaveBeenCalledTimes(2);
310
+ expect(onSharedRecovered).toHaveBeenCalledOnce();
311
+ });
312
+
313
+ it('preserves managed lifecycle controls', async () => {
314
+ const restartManaged = vi.fn(async () => {});
315
+ const triggerManagedHealthCheck = vi.fn(async () => {});
316
+ const managed = createOpenCodeLifecycleControls({
317
+ getKind: () => 'managed-owned',
318
+ restartLifecycle: restartManaged,
319
+ triggerLifecycleHealthCheck: triggerManagedHealthCheck,
320
+ });
321
+
322
+ await managed.restart('manual');
323
+ await managed.triggerHealthCheck();
324
+
325
+ expect(restartManaged).toHaveBeenCalledWith('manual');
326
+ expect(triggerManagedHealthCheck).toHaveBeenCalledTimes(1);
327
+ });
328
+
329
+ it('requires a shared runtime for shared-local composition', () => {
330
+ expect(() => createOpenCodeConnectionAdapter({ kind: 'shared-local' })).toThrow(
331
+ 'Shared OpenCode connection requires a shared service runtime',
332
+ );
333
+ });
334
+
335
+ it('reads shared URL and auth from the shared runtime at call time without owning its process', () => {
336
+ let baseUrl = 'http://127.0.0.1:4096';
337
+ let headers = { authorization: 'Basic first' };
338
+ const adapter = createOpenCodeConnectionAdapter({
339
+ kind: 'shared-local',
340
+ sharedRuntime: {
341
+ getBaseUrl: () => baseUrl,
342
+ getHeaders: () => ({ ...headers }),
343
+ },
344
+ });
345
+
346
+ expect(adapter.getOpenCodeConnectionKind()).toBe('shared-local');
347
+ expect(adapter.getOpenCodeBaseUrl()).toBe('http://127.0.0.1:4096');
348
+ expect(adapter.getOpenCodeAuthHeaders()).toEqual({ authorization: 'Basic first' });
349
+ expect(adapter.ownsOpenCodeProcess()).toBe(false);
350
+
351
+ baseUrl = 'http://127.0.0.1:5096';
352
+ headers = { authorization: 'Basic replacement' };
353
+ expect(adapter.getOpenCodeBaseUrl()).toBe('http://127.0.0.1:5096');
354
+ expect(adapter.getOpenCodeAuthHeaders()).toEqual({ authorization: 'Basic replacement' });
355
+ });
356
+ });
357
+
131
358
  describe('OpenCode lifecycle', () => {
132
359
  it('uses the resolved binary directly on startup and managed restart without an env override', async () => {
133
360
  delete process.env.OPENCODE_BINARY;
@@ -240,6 +467,33 @@ describe('OpenCode lifecycle', () => {
240
467
  expect(runtime.testState.openCodeBaseUrl).toBe('http://seamus:4095');
241
468
  });
242
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
+
243
497
  it('warms recently used directories after a successful bootstrap', async () => {
244
498
  const fetchMock = vi.fn(async () => ({
245
499
  ok: true,
@@ -2,6 +2,17 @@ import { appendManagedPlugin } from './managed-plugin-config.js';
2
2
 
3
3
  export const MANAGED_CONFIG_FILE_NAME = 'opencode.managed.json';
4
4
 
5
+ /**
6
+ * Built-in OpenCode plugins OpenChamber switches off for its managed child.
7
+ *
8
+ * `opencode.browser` only works when OpenCode's own desktop app attaches a
9
+ * browser to the session; OpenChamber does not implement that protocol, so its
10
+ * tools always fail with `browser.disconnected` and agents conclude browsing is
11
+ * unavailable instead of using `openchamber_web`. A project config that lists
12
+ * `opencode.browser` still re-enables it, because that layer sits above ours.
13
+ */
14
+ const DISABLED_BUILTIN_PLUGINS = ['-opencode.browser'];
15
+
5
16
  /**
6
17
  * The managed OpenCode config layer.
7
18
  *
@@ -12,7 +23,8 @@ export const MANAGED_CONFIG_FILE_NAME = 'opencode.managed.json';
12
23
  * process — where `OPENCODE_CONFIG_CONTENT` (an environment variable) could
13
24
  * only ever change across a restart.
14
25
  *
15
- * The file is OpenChamber-owned and holds nothing but `plugins`. It sits above
26
+ * The file is OpenChamber-owned and holds nothing but `plugins`: the built-in
27
+ * plugins OpenChamber disables, then its own plugin directories. It sits above
16
28
  * the user's global `opencode.json` and below their project config, so it never
17
29
  * shadows a project-level choice.
18
30
  *
@@ -46,7 +58,7 @@ export const createManagedConfigRuntime = ({
46
58
 
47
59
  const writeConfigFile = async (pluginDirectories) => {
48
60
  await fsPromises.mkdir(dataDir, { recursive: true });
49
- const text = `${JSON.stringify({ plugins: pluginDirectories }, null, 2)}\n`;
61
+ const text = `${JSON.stringify({ plugins: [...DISABLED_BUILTIN_PLUGINS, ...pluginDirectories] }, null, 2)}\n`;
50
62
  // Temp + rename: OpenCode reacts to the write immediately, and a partial
51
63
  // read would make it drop every managed plugin until the next change.
52
64
  const tmp = `${filePath}.tmp-${process.pid}-${Date.now()}`;
@@ -69,10 +81,11 @@ export const createManagedConfigRuntime = ({
69
81
  const includeControl = settings?.agentControlToolEnabled !== false;
70
82
  const includeWeb = settings?.agentWebToolEnabled !== false;
71
83
  const includeMemory = isAgentMemoryAvailable() && settings?.agentMemoryToolEnabled === true;
84
+ const includeNotify = settings?.agentNotifyToolEnabled === true;
72
85
 
73
86
  const directories = [];
74
- if (agentToolRuntime && (includeControl || includeWeb || includeMemory)) {
75
- directories.push(await agentToolRuntime.materializePlugin({ includeControl, includeWeb, includeMemory }));
87
+ if (agentToolRuntime && (includeControl || includeWeb || includeMemory || includeNotify)) {
88
+ directories.push(await agentToolRuntime.materializePlugin({ includeControl, includeWeb, includeMemory, includeNotify }));
76
89
  }
77
90
  return directories;
78
91
  };
@@ -89,9 +102,8 @@ export const createManagedConfigRuntime = ({
89
102
  await writeConfigFile(directories);
90
103
  return { ...childEnv, OPENCODE_CONFIG: filePath };
91
104
  }
92
- if (directories.length === 0) return childEnv;
93
105
  let content = env.OPENCODE_CONFIG_CONTENT;
94
- for (const directory of directories) {
106
+ for (const directory of [...DISABLED_BUILTIN_PLUGINS, ...directories]) {
95
107
  content = appendManagedPlugin(content, directory, 'managed plugin');
96
108
  }
97
109
  return { ...childEnv, OPENCODE_CONFIG_CONTENT: content };
@@ -52,7 +52,7 @@ describe('managed OpenCode config file', () => {
52
52
  expect(childEnv.OPENCODE_CONFIG_CONTENT).toBeUndefined();
53
53
  expect(childEnv.OPENCHAMBER_AGENT_TOOL_TOKEN).toBe('token');
54
54
  expect(await readConfigFile()).toEqual({
55
- plugins: [path.join(dataDir, 'agent-tool', 'openchamber-agent-tool')],
55
+ plugins: ['-opencode.browser', path.join(dataDir, 'agent-tool', 'openchamber-agent-tool')],
56
56
  });
57
57
  });
58
58
 
@@ -61,7 +61,7 @@ describe('managed OpenCode config file', () => {
61
61
 
62
62
  await runtime.buildManagedChildEnv();
63
63
 
64
- for (const directory of (await readConfigFile()).plugins) {
64
+ for (const directory of (await readConfigFile()).plugins.filter((entry) => !entry.startsWith('-'))) {
65
65
  expect((await fs.stat(directory)).isDirectory()).toBe(true);
66
66
  }
67
67
  });
@@ -74,7 +74,7 @@ describe('managed OpenCode config file', () => {
74
74
  const childEnv = await runtime.buildManagedChildEnv();
75
75
 
76
76
  expect(agentToolRuntime.materializePlugin).not.toHaveBeenCalled();
77
- expect(await readConfigFile()).toEqual({ plugins: [] });
77
+ expect(await readConfigFile()).toEqual({ plugins: ['-opencode.browser'] });
78
78
  // A tool switched on later reaches a process that can already call back.
79
79
  expect(childEnv.OPENCHAMBER_AGENT_TOOL_TOKEN).toBe('token');
80
80
  });
@@ -89,7 +89,7 @@ describe('managed OpenCode config file', () => {
89
89
  expect(await runtime.refreshManagedConfigFile()).toEqual({ updated: true });
90
90
 
91
91
  const { plugins } = await readConfigFile();
92
- expect(plugins).toEqual([]);
92
+ expect(plugins).toEqual(['-opencode.browser']);
93
93
  expect(agentToolRuntime.createChildEnv).toHaveBeenCalledTimes(1);
94
94
  });
95
95
 
@@ -102,11 +102,27 @@ describe('managed OpenCode config file', () => {
102
102
  current.settings = { agentControlToolEnabled: false, agentWebToolEnabled: true };
103
103
  await runtime.refreshManagedConfigFile();
104
104
 
105
- expect((await readConfigFile()).plugins).toHaveLength(1);
105
+ expect((await readConfigFile()).plugins).toHaveLength(2);
106
106
  expect(agentToolRuntime.materializePlugin).toHaveBeenCalledWith({
107
107
  includeControl: false,
108
108
  includeWeb: true,
109
109
  includeMemory: false,
110
+ includeNotify: false,
111
+ });
112
+ });
113
+
114
+ it('injects the notify tool only when it is switched on', async () => {
115
+ const { runtime, agentToolRuntime } = await createHarness({
116
+ settings: { agentControlToolEnabled: false, agentWebToolEnabled: false, agentNotifyToolEnabled: true },
117
+ });
118
+
119
+ await runtime.buildManagedChildEnv();
120
+
121
+ expect(agentToolRuntime.materializePlugin).toHaveBeenCalledWith({
122
+ includeControl: false,
123
+ includeWeb: false,
124
+ includeMemory: false,
125
+ includeNotify: true,
110
126
  });
111
127
  });
112
128
 
@@ -121,7 +137,7 @@ describe('managed OpenCode config file', () => {
121
137
  expect(childEnv.OPENCODE_CONFIG).toBeUndefined();
122
138
  expect(JSON.parse(childEnv.OPENCODE_CONFIG_CONTENT)).toEqual({
123
139
  model: 'test/model',
124
- plugins: [path.join(dataDir, 'agent-tool', 'openchamber-agent-tool')],
140
+ plugins: ['-opencode.browser', path.join(dataDir, 'agent-tool', 'openchamber-agent-tool')],
125
141
  });
126
142
  await expect(fs.stat(path.join(dataDir, MANAGED_CONFIG_FILE_NAME))).rejects.toThrow();
127
143
  expect(await runtime.refreshManagedConfigFile()).toEqual({ updated: false, reason: 'external-config' });
@@ -147,6 +163,6 @@ describe('managed OpenCode config file', () => {
147
163
  await runtime.buildManagedChildEnv();
148
164
 
149
165
  expect(agentToolRuntime.materializePlugin).not.toHaveBeenCalled();
150
- expect(await readConfigFile()).toEqual({ plugins: [] });
166
+ expect(await readConfigFile()).toEqual({ plugins: ['-opencode.browser'] });
151
167
  });
152
168
  });
@@ -48,9 +48,14 @@ function ensureProjectMcpConfigPath(workingDirectory) {
48
48
  return path.join(configDir, 'opencode.json');
49
49
  }
50
50
 
51
- /** Same precedence as `getJsonEntrySource`: custom > project > user. */
51
+ /** Same precedence as `getJsonEntrySource`: custom > project > user override > user. */
52
52
  function readMcpEntriesAcrossLayers(layers) {
53
- return readLayeredMcpEntries([layers?.userConfig, layers?.projectConfig, layers?.customConfig]);
53
+ return readLayeredMcpEntries([
54
+ layers?.userConfig,
55
+ layers?.userOverrideConfig,
56
+ layers?.projectConfig,
57
+ layers?.customConfig,
58
+ ]);
54
59
  }
55
60
 
56
61
  function listMcpConfigs(workingDirectory) {