@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,155 @@
1
+ import { access } from 'node:fs/promises';
2
+ import { homedir } from 'node:os';
3
+ import { join } from 'node:path';
4
+
5
+ import { Service } from '@opencode/client/service';
6
+ import { z } from 'zod';
7
+
8
+ import { isSupportedOpenCodeVersion, readOpenCodeInfo } from './compatibility.js';
9
+
10
+ const endpointSchema = z.object({
11
+ url: z.string(),
12
+ auth: z.object({
13
+ type: z.literal('basic'),
14
+ username: z.string(),
15
+ password: z.string(),
16
+ }).optional(),
17
+ });
18
+
19
+ const createConnectionError = (code, message, details = {}, cause) => {
20
+ const error = new Error(message, cause === undefined ? undefined : { cause });
21
+ error.code = code;
22
+ Object.assign(error, details);
23
+ return error;
24
+ };
25
+
26
+ const unavailableError = (message, cause) => createConnectionError(
27
+ 'OPENCODE_SERVICE_UNAVAILABLE',
28
+ message,
29
+ {},
30
+ cause,
31
+ );
32
+
33
+ const normalizeEndpoint = (endpoint) => {
34
+ const parsed = endpointSchema.safeParse(endpoint);
35
+ if (!parsed.success) {
36
+ throw unavailableError('OpenCode shared service did not return an endpoint');
37
+ }
38
+
39
+ let url;
40
+ try {
41
+ url = new URL(parsed.data.url);
42
+ } catch (cause) {
43
+ throw unavailableError('OpenCode shared service returned an invalid URL', cause);
44
+ }
45
+ if ((url.protocol !== 'http:' && url.protocol !== 'https:') || !url.hostname) {
46
+ throw unavailableError('OpenCode shared service returned an unsupported URL');
47
+ }
48
+
49
+ return { ...parsed.data, url: url.origin };
50
+ };
51
+
52
+ const defaultRegistrationFile = () => join(
53
+ process.env.XDG_STATE_HOME ?? join(homedir(), '.local', 'state'),
54
+ 'opencode',
55
+ 'service.json',
56
+ );
57
+
58
+ const registrationExists = async (options = {}) => {
59
+ try {
60
+ await access(options.file ?? defaultRegistrationFile());
61
+ return true;
62
+ } catch (error) {
63
+ if (error?.code === 'ENOENT') return false;
64
+ throw error;
65
+ }
66
+ };
67
+
68
+ export const createSharedOpenCodeServiceRuntime = ({
69
+ service = Service,
70
+ fetchImpl = fetch,
71
+ ensureOptions,
72
+ readInfo = readOpenCodeInfo,
73
+ isSupportedVersion = isSupportedOpenCodeVersion,
74
+ hasRegistration = registrationExists,
75
+ }) => {
76
+ let activeConnection = null;
77
+
78
+ const resolveEndpoint = async () => {
79
+ let discovered;
80
+ try {
81
+ discovered = await service.discover(ensureOptions);
82
+ } catch (cause) {
83
+ throw unavailableError('Could not discover the OpenCode shared service', cause);
84
+ }
85
+ if (discovered) return normalizeEndpoint(discovered);
86
+
87
+ try {
88
+ if (await hasRegistration(ensureOptions)) {
89
+ throw unavailableError(
90
+ 'An incompatible or unhealthy OpenCode shared service is already registered',
91
+ );
92
+ }
93
+ } catch (cause) {
94
+ if (cause?.code === 'OPENCODE_SERVICE_UNAVAILABLE') throw cause;
95
+ throw unavailableError('Could not inspect the OpenCode shared service registration', cause);
96
+ }
97
+ try {
98
+ return normalizeEndpoint(await service.ensure(ensureOptions));
99
+ } catch (cause) {
100
+ if (cause?.code === 'OPENCODE_SERVICE_UNAVAILABLE') throw cause;
101
+ throw unavailableError('Could not start the OpenCode shared service', cause);
102
+ }
103
+ };
104
+
105
+ const probe = async (endpoint) => {
106
+ let headers;
107
+ try {
108
+ headers = { ...(service.headers(endpoint) ?? {}) };
109
+ } catch (cause) {
110
+ throw unavailableError('Could not authenticate with the OpenCode shared service', cause);
111
+ }
112
+ let info;
113
+ try {
114
+ const response = await fetchImpl(new URL('/api/info', endpoint.url), {
115
+ headers: { Accept: 'application/json', ...headers },
116
+ });
117
+ info = await readInfo(response);
118
+ } catch (cause) {
119
+ throw unavailableError('Could not reach the OpenCode shared service', cause);
120
+ }
121
+ if (!info) {
122
+ throw unavailableError('OpenCode shared service did not return valid service information');
123
+ }
124
+ if (!isSupportedVersion(info.version)) {
125
+ throw createConnectionError(
126
+ 'OPENCODE_INCOMPATIBLE',
127
+ `OpenCode shared service version ${info.version} is incompatible`,
128
+ { foundVersion: info.version },
129
+ );
130
+ }
131
+
132
+ return {
133
+ kind: 'shared-local',
134
+ endpoint,
135
+ version: info.version,
136
+ };
137
+ };
138
+
139
+ const establishConnection = async () => {
140
+ const replacement = await probe(await resolveEndpoint());
141
+ activeConnection = replacement;
142
+ return replacement;
143
+ };
144
+
145
+ return {
146
+ connect: establishConnection,
147
+ recover: establishConnection,
148
+ getConnection: () => activeConnection,
149
+ getBaseUrl: () => activeConnection?.endpoint.url ?? null,
150
+ getHeaders: () => activeConnection ? { ...(service.headers(activeConnection.endpoint) ?? {}) } : {},
151
+ dispose: async () => {
152
+ activeConnection = null;
153
+ },
154
+ };
155
+ };
@@ -0,0 +1,180 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+
3
+ import { createSharedOpenCodeServiceRuntime } from './shared-service-runtime.js';
4
+
5
+ const endpointA = {
6
+ url: 'http://127.0.0.1:4096',
7
+ auth: { type: 'basic', username: 'opencode', password: 'original' },
8
+ };
9
+ const endpointB = {
10
+ url: 'http://127.0.0.1:5096',
11
+ auth: { type: 'basic', username: 'opencode', password: 'replacement' },
12
+ };
13
+
14
+ const createRuntime = ({
15
+ discover = vi.fn().mockResolvedValue(endpointA),
16
+ ensure = vi.fn(),
17
+ stop = vi.fn(),
18
+ headers = vi.fn((endpoint) => endpoint.auth
19
+ ? { authorization: `Basic ${endpoint.auth.password}` }
20
+ : undefined),
21
+ fetchImpl = vi.fn().mockResolvedValue(Response.json({ version: '2.0.15' })),
22
+ infoVersion,
23
+ readInfo = async (response) => infoVersion === undefined
24
+ ? response.json()
25
+ : { version: infoVersion },
26
+ isSupportedVersion = (version) => version === '2.0.15',
27
+ ensureOptions = { command: ['opencode', 'serve', '--service'] },
28
+ hasRegistration = vi.fn().mockResolvedValue(false),
29
+ } = {}) => createSharedOpenCodeServiceRuntime({
30
+ service: { discover, ensure, stop, headers },
31
+ fetchImpl,
32
+ ensureOptions,
33
+ readInfo,
34
+ isSupportedVersion,
35
+ hasRegistration,
36
+ });
37
+
38
+ describe('shared OpenCode service runtime', () => {
39
+ it('uses a compatible discovered service without ensuring another one', async () => {
40
+ const discover = vi.fn().mockResolvedValue(endpointA);
41
+ const ensure = vi.fn();
42
+ const runtime = createRuntime({ discover, ensure, infoVersion: '2.0.15' });
43
+
44
+ await expect(runtime.connect()).resolves.toMatchObject({
45
+ kind: 'shared-local',
46
+ endpoint: endpointA,
47
+ version: '2.0.15',
48
+ });
49
+ expect(ensure).not.toHaveBeenCalled();
50
+ });
51
+
52
+ it('rejects an incompatible discovered service without replacing it', async () => {
53
+ const discover = vi.fn().mockResolvedValue(endpointA);
54
+ const ensure = vi.fn();
55
+ const runtime = createRuntime({ discover, ensure, infoVersion: '1.99.0' });
56
+
57
+ await expect(runtime.connect()).rejects.toMatchObject({
58
+ code: 'OPENCODE_INCOMPATIBLE',
59
+ foundVersion: '1.99.0',
60
+ });
61
+ expect(ensure).not.toHaveBeenCalled();
62
+ });
63
+
64
+ it('refuses to ensure when discovery hides an existing incompatible or unhealthy registration', async () => {
65
+ const discover = vi.fn().mockResolvedValue(undefined);
66
+ const ensure = vi.fn();
67
+ const runtime = createRuntime({
68
+ discover,
69
+ ensure,
70
+ hasRegistration: vi.fn().mockResolvedValue(true),
71
+ });
72
+
73
+ await expect(runtime.connect()).rejects.toMatchObject({
74
+ code: 'OPENCODE_SERVICE_UNAVAILABLE',
75
+ });
76
+ expect(ensure).not.toHaveBeenCalled();
77
+ });
78
+
79
+ it('ensures a service only when discovery returns no endpoint', async () => {
80
+ const discover = vi.fn().mockResolvedValue(undefined);
81
+ const ensure = vi.fn().mockResolvedValue(endpointA);
82
+ const runtime = createRuntime({ discover, ensure, infoVersion: '2.0.15' });
83
+
84
+ await expect(runtime.connect()).resolves.toMatchObject({ endpoint: endpointA });
85
+ expect(ensure).toHaveBeenCalledTimes(1);
86
+ });
87
+
88
+ it('refreshes URL and auth when recovery discovers a replacement endpoint', async () => {
89
+ const discover = vi.fn()
90
+ .mockResolvedValueOnce(endpointA)
91
+ .mockResolvedValueOnce(endpointB);
92
+ const runtime = createRuntime({ discover, infoVersion: '2.0.15' });
93
+
94
+ await runtime.connect();
95
+ await runtime.recover();
96
+
97
+ expect(runtime.getBaseUrl()).toBe(endpointB.url);
98
+ expect(runtime.getHeaders()).toEqual({ authorization: 'Basic replacement' });
99
+ });
100
+
101
+ it('keeps the active connection when a replacement is incompatible', async () => {
102
+ const discover = vi.fn()
103
+ .mockResolvedValueOnce(endpointA)
104
+ .mockResolvedValueOnce(endpointB);
105
+ const readInfo = vi.fn()
106
+ .mockResolvedValueOnce({ version: '2.0.15' })
107
+ .mockResolvedValueOnce({ version: '1.99.0' });
108
+ const runtime = createRuntime({ discover, readInfo });
109
+
110
+ await runtime.connect();
111
+ await expect(runtime.recover()).rejects.toMatchObject({ code: 'OPENCODE_INCOMPATIBLE' });
112
+
113
+ expect(runtime.getBaseUrl()).toBe(endpointA.url);
114
+ expect(runtime.getHeaders()).toEqual({ authorization: 'Basic original' });
115
+ });
116
+
117
+ it('dispose does not stop the shared service', async () => {
118
+ const stop = vi.fn();
119
+ const runtime = createRuntime({ stop, infoVersion: '2.0.15' });
120
+ await runtime.connect();
121
+ await runtime.dispose();
122
+ expect(stop).not.toHaveBeenCalled();
123
+ expect(runtime.getConnection()).toBeNull();
124
+ });
125
+
126
+ it.each([
127
+ [undefined],
128
+ [{}],
129
+ [{ url: '' }],
130
+ [{ url: 'file:///tmp/opencode.sock' }],
131
+ [{ url: 'not a url' }],
132
+ ])('rejects a malformed endpoint (%j)', async (endpoint) => {
133
+ const runtime = createRuntime({ discover: vi.fn().mockResolvedValue(endpoint) });
134
+
135
+ await expect(runtime.connect()).rejects.toMatchObject({ code: 'OPENCODE_SERVICE_UNAVAILABLE' });
136
+ });
137
+
138
+ it('reports an authenticated service as unavailable when /api/info returns 401', async () => {
139
+ const runtime = createRuntime({
140
+ fetchImpl: vi.fn().mockResolvedValue(new Response(null, { status: 401 })),
141
+ readInfo: async () => null,
142
+ });
143
+
144
+ await expect(runtime.connect()).rejects.toMatchObject({ code: 'OPENCODE_SERVICE_UNAVAILABLE' });
145
+ });
146
+
147
+ it('reports discovery failures as unavailable without ensuring a replacement', async () => {
148
+ const ensure = vi.fn();
149
+ const runtime = createRuntime({
150
+ discover: vi.fn().mockRejectedValue(new Error('registration unavailable')),
151
+ ensure,
152
+ });
153
+
154
+ await expect(runtime.connect()).rejects.toMatchObject({ code: 'OPENCODE_SERVICE_UNAVAILABLE' });
155
+ expect(ensure).not.toHaveBeenCalled();
156
+ });
157
+
158
+ it('reports an unreachable discovered endpoint as unavailable without ensuring a replacement', async () => {
159
+ const ensure = vi.fn();
160
+ const runtime = createRuntime({
161
+ ensure,
162
+ fetchImpl: vi.fn().mockRejectedValue(new Error('connection refused')),
163
+ });
164
+
165
+ await expect(runtime.connect()).rejects.toMatchObject({ code: 'OPENCODE_SERVICE_UNAVAILABLE' });
166
+ expect(ensure).not.toHaveBeenCalled();
167
+ });
168
+
169
+ it('uses empty headers when Service.headers returns undefined', async () => {
170
+ const fetchImpl = vi.fn().mockResolvedValue(Response.json({ version: '2.0.15' }));
171
+ const runtime = createRuntime({ headers: vi.fn().mockReturnValue(undefined), fetchImpl });
172
+
173
+ await runtime.connect();
174
+
175
+ expect(runtime.getHeaders()).toEqual({});
176
+ expect(fetchImpl).toHaveBeenCalledWith(new URL(`${endpointA.url}/api/info`), {
177
+ headers: { Accept: 'application/json' },
178
+ });
179
+ });
180
+ });
@@ -280,11 +280,16 @@ function readConfigLayer(filePath) {
280
280
  function readConfigLayers(workingDirectory) {
281
281
  const { userPaths, projectPath, customPath } = getConfigPaths(workingDirectory);
282
282
  const userPath = getPrimaryUserConfigPath(userPaths);
283
+ // OpenCode loads every global config file in order, so an `opencode.jsonc`
284
+ // next to `opencode.json` overrides it. New entries still go to the primary
285
+ // file; entries found in the override are edited where they live.
286
+ const userOverridePath = userPaths.find((candidate) => candidate !== userPath && fs.existsSync(candidate)) ?? null;
283
287
  const userLayer = readConfigLayer(userPath);
288
+ const userOverrideLayer = readConfigLayer(userOverridePath);
284
289
  const projectLayer = readConfigLayer(projectPath);
285
290
  const customLayer = readConfigLayer(customPath);
286
291
  const mergedConfig = mergeConfigs(
287
- mergeConfigs(userLayer.config, projectLayer.config),
292
+ mergeConfigs(mergeConfigs(userLayer.config, userOverrideLayer.config), projectLayer.config),
288
293
  customLayer.config,
289
294
  );
290
295
 
@@ -292,6 +297,9 @@ function readConfigLayers(workingDirectory) {
292
297
  if (userLayer.error) {
293
298
  layerErrors.push({ path: userPath, code: userLayer.error.code, message: userLayer.error.message });
294
299
  }
300
+ if (userOverrideLayer.error && userOverridePath) {
301
+ layerErrors.push({ path: userOverridePath, code: userOverrideLayer.error.code, message: userOverrideLayer.error.message });
302
+ }
295
303
  if (projectLayer.error && projectPath) {
296
304
  layerErrors.push({ path: projectPath, code: projectLayer.error.code, message: projectLayer.error.message });
297
305
  }
@@ -301,10 +309,11 @@ function readConfigLayers(workingDirectory) {
301
309
 
302
310
  return {
303
311
  userConfig: userLayer.config,
312
+ userOverrideConfig: userOverrideLayer.config,
304
313
  projectConfig: projectLayer.config,
305
314
  customConfig: customLayer.config,
306
315
  mergedConfig,
307
- paths: { userPath, projectPath, customPath },
316
+ paths: { userPath, userOverridePath, projectPath, customPath },
308
317
  layerErrors,
309
318
  };
310
319
  }
@@ -407,6 +416,12 @@ function getJsonEntrySource(layers, sectionKind, entryName) {
407
416
  if (project) return project;
408
417
  }
409
418
 
419
+ if (paths.userOverridePath) {
420
+ throwIfLayerError(layers, paths.userOverridePath);
421
+ const userOverride = found(layers.userOverrideConfig, paths.userOverridePath);
422
+ if (userOverride) return userOverride;
423
+ }
424
+
410
425
  throwIfLayerError(layers, paths.userPath);
411
426
  const user = found(userConfig, paths.userPath);
412
427
  if (user) return user;
@@ -509,7 +524,20 @@ function walkSkillMdFiles(rootDir) {
509
524
  if (!rootDir || !fs.existsSync(rootDir)) return [];
510
525
 
511
526
  const results = [];
527
+ // Real paths of the directories on the current walk path. Links (symlinks and
528
+ // Windows junctions) are followed at any depth; a link back to one of its own
529
+ // ancestors is skipped instead of recursing forever. Two links to the same
530
+ // target elsewhere in the tree are both walked, as the top level always did.
531
+ const ancestors = new Set();
512
532
  const walk = (dir) => {
533
+ let realDir;
534
+ try {
535
+ realDir = fs.realpathSync(dir);
536
+ } catch {
537
+ return;
538
+ }
539
+ if (ancestors.has(realDir)) return;
540
+
513
541
  let entries = [];
514
542
  try {
515
543
  entries = fs.readdirSync(dir, { withFileTypes: true });
@@ -517,16 +545,33 @@ function walkSkillMdFiles(rootDir) {
517
545
  return;
518
546
  }
519
547
 
548
+ ancestors.add(realDir);
549
+
520
550
  for (const entry of entries) {
521
551
  const fullPath = path.join(dir, entry.name);
522
- if (entry.isDirectory()) {
552
+ // Junctions report as links, not directories. A link whose target cannot be
553
+ // stat'ed is skipped, the way an unreadable directory is, instead of failing
554
+ // the whole scan.
555
+ let isDirectoryEntry = entry.isDirectory();
556
+ let isFileEntry = entry.isFile();
557
+ if (entry.isSymbolicLink()) {
558
+ try {
559
+ const target = fs.statSync(fullPath);
560
+ isDirectoryEntry = target.isDirectory();
561
+ isFileEntry = target.isFile();
562
+ } catch {
563
+ continue;
564
+ }
565
+ }
566
+ if (isDirectoryEntry) {
523
567
  walk(fullPath);
524
568
  continue;
525
569
  }
526
- if (entry.isFile() && entry.name === 'SKILL.md') {
570
+ if (isFileEntry && entry.name === 'SKILL.md') {
527
571
  results.push(fullPath);
528
572
  }
529
573
  }
574
+ ancestors.delete(realDir);
530
575
  };
531
576
 
532
577
  walk(rootDir);
@@ -3,7 +3,7 @@ import fs from 'fs';
3
3
  import os from 'os';
4
4
  import path from 'path';
5
5
 
6
- import { parseMdFile, writeMdFile, readConfigFile, readConfigLayers, writeConfig } from './shared.js';
6
+ import { parseMdFile, writeMdFile, readConfigFile, readConfigLayers, writeConfig, walkSkillMdFiles } from './shared.js';
7
7
  import { updateAgent } from './agents.js';
8
8
  import { updateMcpConfig } from './mcp.js';
9
9
 
@@ -388,3 +388,80 @@ describe('readConfigFile / writeConfig JSONC safety (issue #2923)', () => {
388
388
  }
389
389
  });
390
390
  });
391
+
392
+ describe('walkSkillMdFiles', () => {
393
+ beforeEach(() => {
394
+ fs.rmSync(FIXTURE_DIR, { recursive: true, force: true });
395
+ fs.mkdirSync(FIXTURE_DIR, { recursive: true });
396
+ });
397
+
398
+ afterEach(() => {
399
+ fs.rmSync(FIXTURE_DIR, { recursive: true, force: true });
400
+ });
401
+
402
+ const writeSkill = (relativeDir) => writeFixture(path.join(relativeDir, 'SKILL.md'), STANDARD_MD);
403
+ // A junction needs no elevation on Windows, which is what Skills Manager deploys;
404
+ // the type argument is ignored on POSIX.
405
+ const linkDirectory = (target, relativeLink) =>
406
+ fs.symlinkSync(target, path.join(FIXTURE_DIR, relativeLink), process.platform === 'win32' ? 'junction' : 'dir');
407
+ const foundRelative = () =>
408
+ walkSkillMdFiles(FIXTURE_DIR)
409
+ .map((found) => path.relative(FIXTURE_DIR, found).split(path.sep).join('/'))
410
+ .sort();
411
+
412
+ it('walks a skill deployed as a link inside the scanned root', () => {
413
+ writeSkill('real-skill');
414
+ fs.mkdirSync(path.join(FIXTURE_DIR, 'collection'), { recursive: true });
415
+ writeSkill(path.join('collection', 'nested-skill'));
416
+ linkDirectory(path.join(FIXTURE_DIR, 'real-skill'), 'linked-skill');
417
+ linkDirectory(path.join(FIXTURE_DIR, 'collection'), 'collection-link');
418
+
419
+ expect(foundRelative()).toEqual([
420
+ 'collection-link/nested-skill/SKILL.md',
421
+ 'collection/nested-skill/SKILL.md',
422
+ 'linked-skill/SKILL.md',
423
+ 'real-skill/SKILL.md',
424
+ ]);
425
+ });
426
+
427
+ it('ends on a link that points back into the scanned tree', () => {
428
+ fs.mkdirSync(path.join(FIXTURE_DIR, 'loopdir'), { recursive: true });
429
+ writeSkill('loopdir');
430
+ linkDirectory(FIXTURE_DIR, path.join('loopdir', 'back'));
431
+
432
+ expect(foundRelative()).toEqual(['loopdir/SKILL.md']);
433
+ });
434
+
435
+ it('skips a link whose target is gone and keeps the rest of the scan', () => {
436
+ writeSkill('real-skill');
437
+ fs.mkdirSync(path.join(FIXTURE_DIR, 'gone-skill'), { recursive: true });
438
+ linkDirectory(path.join(FIXTURE_DIR, 'gone-skill'), 'dangling-link');
439
+ fs.rmSync(path.join(FIXTURE_DIR, 'gone-skill'), { recursive: true, force: true });
440
+
441
+ expect(foundRelative()).toEqual(['real-skill/SKILL.md']);
442
+ });
443
+
444
+ it('follows a link nested below the top level of the scanned root', () => {
445
+ const outside = `${FIXTURE_DIR}-outside`;
446
+ fs.rmSync(outside, { recursive: true, force: true });
447
+ fs.mkdirSync(path.join(outside, 'deep-skill'), { recursive: true });
448
+ fs.writeFileSync(path.join(outside, 'deep-skill', 'SKILL.md'), STANDARD_MD);
449
+ fs.mkdirSync(path.join(FIXTURE_DIR, 'group'), { recursive: true });
450
+ try {
451
+ linkDirectory(path.join(outside, 'deep-skill'), path.join('group', 'linked'));
452
+ expect(foundRelative()).toEqual(['group/linked/SKILL.md']);
453
+ } finally {
454
+ fs.rmSync(outside, { recursive: true, force: true });
455
+ }
456
+ });
457
+
458
+ it('ends on a nested link loop between two directories', () => {
459
+ fs.mkdirSync(path.join(FIXTURE_DIR, 'a'), { recursive: true });
460
+ fs.mkdirSync(path.join(FIXTURE_DIR, 'b'), { recursive: true });
461
+ writeSkill('a');
462
+ linkDirectory(path.join(FIXTURE_DIR, 'b'), path.join('a', 'to-b'));
463
+ linkDirectory(path.join(FIXTURE_DIR, 'a'), path.join('b', 'to-a'));
464
+
465
+ expect(foundRelative()).toEqual(['a/SKILL.md', 'b/to-a/SKILL.md']);
466
+ });
467
+ });
@@ -13,18 +13,20 @@ export const createGracefulShutdownRuntime = (dependencies) => {
13
13
  contextObligatoryRuntime,
14
14
  messageQueueRuntime,
15
15
  scheduledTasksRuntime,
16
+ globalEventHub,
16
17
  getHealthCheckInterval,
17
18
  clearHealthCheckInterval,
18
19
  getTerminalRuntime,
19
20
  setTerminalRuntime,
20
21
  getMessageStreamRuntime,
21
22
  setMessageStreamRuntime,
22
- shouldSkipOpenCodeStop,
23
+ getOpenCodeConnectionKind,
23
24
  getOpenCodePort,
24
25
  getOpenCodeProcess,
25
26
  setOpenCodeProcess,
26
27
  killProcessOnPort,
27
28
  waitForPortRelease,
29
+ disposeSharedOpenCodeService,
28
30
  getServer,
29
31
  getUiAuthController,
30
32
  setUiAuthController,
@@ -38,6 +40,7 @@ export const createGracefulShutdownRuntime = (dependencies) => {
38
40
  getDictationRuntime,
39
41
  getRelayService,
40
42
  getRelayReconcileTimer,
43
+ getSpacesHost = () => null,
41
44
  } = dependencies;
42
45
 
43
46
  let shutdownPromise = null;
@@ -66,7 +69,9 @@ export const createGracefulShutdownRuntime = (dependencies) => {
66
69
  beginGuestServiceShutdown();
67
70
  syncToHmrState();
68
71
  console.log('Starting graceful shutdown...');
69
- const exitProcess = typeof options.exitProcess === 'boolean' ? options.exitProcess : getExitOnShutdown();
72
+ const exitProcess = options.exitProcess === true || options.exitProcess === false
73
+ ? options.exitProcess
74
+ : getExitOnShutdown();
70
75
 
71
76
  // Both embedded stop() and daemon exits use this sequence. Close admission
72
77
  // synchronously above, then stop viewers before draining their services.
@@ -74,9 +79,12 @@ export const createGracefulShutdownRuntime = (dependencies) => {
74
79
  () => clearInterval(getRelayReconcileTimer()),
75
80
  () => getGuestSurfaceRuntime()?.stop(),
76
81
  () => getRealtimeProxyRuntime()?.stop(),
82
+ // The isolated-spaces host, when the switch is on: its connections into spaces end here.
83
+ () => getSpacesHost()?.close(),
77
84
  () => getRelayService()?.stop(),
78
85
  () => getDictationRuntime()?.stop(),
79
86
  () => openCodeWatcherRuntime.stop(),
87
+ () => globalEventHub.stop(),
80
88
  () => sessionRuntime.dispose(),
81
89
  () => sessionAssistRuntime?.stop?.(),
82
90
  () => sessionGoalRuntime?.stop?.(),
@@ -118,7 +126,8 @@ export const createGracefulShutdownRuntime = (dependencies) => {
118
126
  }
119
127
  }
120
128
 
121
- if (!shouldSkipOpenCodeStop()) {
129
+ const connectionKind = getOpenCodeConnectionKind();
130
+ if (connectionKind === 'managed-owned') {
122
131
  const portToKill = getOpenCodePort();
123
132
  const openCodeProcess = getOpenCodeProcess();
124
133
 
@@ -136,8 +145,8 @@ export const createGracefulShutdownRuntime = (dependencies) => {
136
145
  if (!(await waitForPortRelease(portToKill, 5000))) {
137
146
  console.warn(`Timed out waiting for OpenCode port ${portToKill} to be released during shutdown`);
138
147
  }
139
- } else {
140
- console.log('Skipping OpenCode shutdown (external server)');
148
+ } else if (connectionKind === 'shared-local') {
149
+ await disposeSharedOpenCodeService();
141
150
  }
142
151
 
143
152
  const server = getServer();