@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
@@ -42,6 +42,19 @@ describe('project directory runtime', () => {
42
42
  expect(result).toEqual({ ok: true, directory: '/real/path/to/project', requestedDirectory: '/symlink/path/to/project' });
43
43
  });
44
44
 
45
+ it('refuses a directory the host says no to, before it looks at the disk', async () => {
46
+ let statCalls = 0;
47
+ const runtime = createTestRuntime({
48
+ fsPromises: { stat: async () => { statCalls += 1; return { isDirectory: () => true }; }, realpath: async (p) => p },
49
+ refuseDirectory: (candidate) => (candidate.startsWith('/spaces/') ? 'A directory under /spaces/ belongs to an isolated space' : null),
50
+ });
51
+
52
+ expect(await runtime.validateDirectoryPath('/spaces/a1b2c3d4e5f6/repo')).toEqual({ ok: false, error: 'A directory under /spaces/ belongs to an isolated space' });
53
+ expect(statCalls).toBe(0);
54
+ expect(await runtime.validateDirectoryPath('/home/user/project')).toMatchObject({ ok: true });
55
+ expect(statCalls).toBe(1);
56
+ });
57
+
45
58
  it('returns error when candidate is empty', async () => {
46
59
  const runtime = createTestRuntime();
47
60
  const result = await runtime.validateDirectoryPath('');
@@ -0,0 +1,59 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import express from 'express';
3
+ import request from 'supertest';
4
+ import { registerOpenCodeRoutes } from './routes.js';
5
+
6
+ const createApp = (getProviderSources) => {
7
+ const app = express();
8
+ app.use(express.json());
9
+ registerOpenCodeRoutes(app, {
10
+ resolveProjectDirectory: vi.fn(async () => ({ directory: '/projects/app' })),
11
+ getProviderSources,
12
+ });
13
+ return app;
14
+ };
15
+
16
+ describe('GET /api/provider/:providerId/source', () => {
17
+ it('returns the stored config entry next to the layer sources', async () => {
18
+ const stored = {
19
+ name: 'Stored',
20
+ package: 'aisdk:@ai-sdk/openai-compatible',
21
+ env: ['STORED_KEY'],
22
+ settings: { baseURL: 'https://stored.example.com/v1' },
23
+ models: { m: { modelID: 'm', name: 'M' } },
24
+ };
25
+ const getProviderSources = vi.fn(() => ({
26
+ sources: {
27
+ auth: { exists: false },
28
+ user: { exists: false, path: '/user.json' },
29
+ project: { exists: true, path: '/projects/app/opencode.json' },
30
+ custom: { exists: false, path: null },
31
+ },
32
+ config: stored,
33
+ }));
34
+
35
+ const response = await request(createApp(getProviderSources))
36
+ .get('/api/provider/stored-llm/source?directory=/projects/app')
37
+ .expect(200);
38
+
39
+ expect(getProviderSources).toHaveBeenCalledWith('stored-llm', '/projects/app');
40
+ expect(response.body.config).toEqual(stored);
41
+ expect(response.body.sources.project.exists).toBe(true);
42
+ });
43
+
44
+ it('returns null config when no layer defines the provider', async () => {
45
+ const response = await request(createApp(() => ({
46
+ sources: {
47
+ auth: { exists: false },
48
+ user: { exists: false, path: '/user.json' },
49
+ project: { exists: false, path: null },
50
+ custom: { exists: false, path: null },
51
+ },
52
+ config: null,
53
+ })))
54
+ .get('/api/provider/other/source')
55
+ .expect(200);
56
+
57
+ expect(response.body.config).toBeNull();
58
+ });
59
+ });
@@ -11,6 +11,7 @@ import {
11
11
  deleteSectionEntry,
12
12
  toModelVariants,
13
13
  toProviderEntity,
14
+ readStoredProviderEntry,
14
15
  toProviderPackage,
15
16
  toNpmPackage,
16
17
  } from './config-v2.js';
@@ -43,6 +44,8 @@ function getProviderSources(providerId, workingDirectory) {
43
44
  project: { exists: providerExistsIn(projectConfig, providerId), path: paths.projectPath || null },
44
45
  custom: { exists: providerExistsIn(customConfig, providerId), path: paths.customPath },
45
46
  },
47
+ // Same precedence as the edit scope: custom > project > user.
48
+ config: readStoredProviderEntry([customConfig, projectConfig, userConfig], providerId),
46
49
  };
47
50
  }
48
51
 
@@ -436,6 +436,68 @@ describe('custom provider config persistence', () => {
436
436
  }
437
437
  });
438
438
 
439
+ test('getProviderSources returns the stored entry from a JSONC config, v2 shape, without the key', () => {
440
+ const configPath = path.join(projectDir, '.opencode', 'opencode.jsonc');
441
+ fs.mkdirSync(path.dirname(configPath), { recursive: true });
442
+ fs.writeFileSync(configPath, `{
443
+ // v1 spelling, still decoded by OpenCode 2
444
+ "provider": {
445
+ "stored-llm": {
446
+ "name": "Stored",
447
+ "npm": "@ai-sdk/openai-compatible",
448
+ "env": ["STORED_KEY"],
449
+ "options": { "baseURL": "https://stored.example.com/v1", "apiKey": "sk-secret", "timeout": 5 },
450
+ "models": { "m": { "name": "M", "variants": { "high": { "reasoningEffort": "high" } } } },
451
+ },
452
+ },
453
+ }`, 'utf8');
454
+
455
+ const { config } = getProviderSources('stored-llm', projectDir);
456
+ expect(config).toEqual({
457
+ name: 'Stored',
458
+ package: 'aisdk:@ai-sdk/openai-compatible',
459
+ env: ['STORED_KEY'],
460
+ settings: { baseURL: 'https://stored.example.com/v1', timeout: 5 },
461
+ models: { m: { name: 'M', variants: [{ id: 'high', settings: { reasoningEffort: 'high' } }] } },
462
+ });
463
+ expect(getProviderSources('missing-llm', projectDir).config).toBeNull();
464
+ });
465
+
466
+ test('saving the stored entry back keeps env, settings and model fields it does not edit', () => {
467
+ const providerId = 'keep-llm';
468
+ const configPath = path.join(projectDir, '.opencode', 'opencode.json');
469
+ writeJson(configPath, {
470
+ providers: {
471
+ [providerId]: {
472
+ name: 'Keep',
473
+ package: 'aisdk:@ai-sdk/openai-compatible',
474
+ env: ['KEEP_KEY'],
475
+ settings: { baseURL: 'https://keep.example.com/v1', timeout: 5 },
476
+ body: { store: false },
477
+ models: { m: { modelID: 'm', name: 'M', limit: { context: 1000 } } },
478
+ },
479
+ },
480
+ });
481
+
482
+ const { config } = getProviderSources(providerId, projectDir);
483
+ upsertProviderConfig(providerId, {
484
+ name: config.name,
485
+ package: config.package,
486
+ env: config.env,
487
+ settings: { baseURL: config.settings.baseURL },
488
+ models: { m: { name: 'M renamed' } },
489
+ }, projectDir, 'project', { hasStoredAuth: true });
490
+
491
+ expect(readJson(configPath).providers[providerId]).toEqual({
492
+ name: 'Keep',
493
+ package: 'aisdk:@ai-sdk/openai-compatible',
494
+ env: ['KEEP_KEY'],
495
+ settings: { baseURL: 'https://keep.example.com/v1', timeout: 5 },
496
+ body: { store: false },
497
+ models: { m: { modelID: 'm', name: 'M renamed', limit: { context: 1000 } } },
498
+ });
499
+ });
500
+
439
501
  test('custom-scope edit updates custom layer without creating a user entry', () => {
440
502
  const providerId = `custom-scope-${Date.now()}`;
441
503
  const customPath = path.join(projectDir, 'custom-opencode.json');
@@ -236,7 +236,7 @@ const SESSION_LIST_ALLOWED_FIELDS = [
236
236
  'fork',
237
237
  ];
238
238
 
239
- const sanitizeSessionListItem = (session) => {
239
+ export const sanitizeSessionListItem = (session) => {
240
240
  if (!session || typeof session !== 'object' || Array.isArray(session)) {
241
241
  return session;
242
242
  }
@@ -307,6 +307,11 @@ export const registerOpenCodeProxy = (app, deps) => {
307
307
  // migrated.
308
308
  getArchivedSessions = null,
309
309
  getStoredSessionMetadata = null,
310
+ // Isolated spaces, when the feature's switch is on: the merged session list, and the hub
311
+ // whose space events the global SSE stream carries beside the host's. Both absent means
312
+ // the host's own answers go out exactly as before spaces.
313
+ mergeSpaceSessionList = null,
314
+ spaceEventHub = null,
310
315
  } = deps;
311
316
 
312
317
  /**
@@ -547,6 +552,7 @@ export const registerOpenCodeProxy = (app, deps) => {
547
552
  let heartbeatTimer = null;
548
553
  let upstreamStallTimer = null;
549
554
  let didUpstreamStall = false;
555
+ let unsubscribeSpaceEvents = null;
550
556
  let writeQueue = Promise.resolve(true);
551
557
  const sseBoundary = createSseBoundaryTracker();
552
558
 
@@ -641,6 +647,26 @@ export const registerOpenCodeProxy = (app, deps) => {
641
647
  return writeQueue;
642
648
  };
643
649
 
650
+ // The events of isolated spaces ride the global stream too, one block each, written
651
+ // only between the upstream's own blocks so a block of the host's is never cut.
652
+ // A directory in the query or in the header scopes the stream to the host's one directory.
653
+ const isGlobalStream = !new URL(requestUrl, 'http://localhost').searchParams.get('directory') && !req.get('x-opencode-directory');
654
+ const pendingSpaceBlocks = [];
655
+ const flushSpaceBlocks = async () => {
656
+ while (pendingSpaceBlocks.length > 0 && sseBoundary.isAtBoundary() && !abortController.signal.aborted) {
657
+ const canContinue = await enqueueSseWrite(pendingSpaceBlocks.shift());
658
+ if (!canContinue) return false;
659
+ }
660
+ return true;
661
+ };
662
+ if (spaceEventHub && isGlobalStream) {
663
+ unsubscribeSpaceEvents = spaceEventHub.subscribeEvent((event) => {
664
+ if (event.spaceId === null) return;
665
+ pendingSpaceBlocks.push(`data: ${JSON.stringify(event.payload)}\n\n`);
666
+ void flushSpaceBlocks();
667
+ }, { spaces: true });
668
+ }
669
+
644
670
  scheduleHeartbeat();
645
671
  resetUpstreamStallTimer();
646
672
 
@@ -657,6 +683,9 @@ export const registerOpenCodeProxy = (app, deps) => {
657
683
  if (!canContinue) {
658
684
  break;
659
685
  }
686
+ if (!await flushSpaceBlocks()) {
687
+ break;
688
+ }
660
689
  }
661
690
  }
662
691
 
@@ -676,6 +705,7 @@ export const registerOpenCodeProxy = (app, deps) => {
676
705
  res.end();
677
706
  }
678
707
  } finally {
708
+ unsubscribeSpaceEvents?.();
679
709
  if (heartbeatTimer) {
680
710
  clearTimeout(heartbeatTimer);
681
711
  heartbeatTimer = null;
@@ -760,7 +790,13 @@ export const registerOpenCodeProxy = (app, deps) => {
760
790
  }
761
791
 
762
792
  res.setHeader('content-type', result.contentType);
763
- res.json(await overlayOwnedStateOnList(sanitizeSessionListPayload(result.payload)));
793
+ const hostList = await overlayOwnedStateOnList(sanitizeSessionListPayload(result.payload));
794
+ // The first page of the global list carries every space's sessions after the host's; a
795
+ // later page, and a list scoped to one directory, are the host's alone.
796
+ const listQuery = new URL(upstreamPath, 'http://localhost').searchParams;
797
+ const scopedToDirectory = Boolean(listQuery.get('directory') || req.get('x-opencode-directory'));
798
+ const wantsSpaces = typeof mergeSpaceSessionList === 'function' && !listQuery.get('cursor') && !scopedToDirectory;
799
+ res.json(wantsSpaces ? await mergeSpaceSessionList(hostList) : hostList);
764
800
  } catch (error) {
765
801
  if (isAbortError(error)) {
766
802
  return;
@@ -86,7 +86,7 @@ describe('OpenCode upgrade routes', () => {
86
86
  expect(globalThis.fetch).not.toHaveBeenCalled();
87
87
  });
88
88
 
89
- it.each(['external', 'unavailable', 'windows-arm64-workaround'])('rejects %s on the server', async (reason) => {
89
+ it.each(['external', 'unavailable'])('rejects %s on the server', async (reason) => {
90
90
  const { app, dependencies } = createApp({ getOpenCodeUpgradeCapability: () => ({ supported: false, reason }) });
91
91
  await request(app).post('/api/opencode/upgrade').send({}).expect(409);
92
92
  expect(dependencies.upgradeOpenCodeCli).not.toHaveBeenCalled();
@@ -10,7 +10,7 @@ import { getClaudeCliAuthStatus } from './claude-cli-auth.js';
10
10
  import { OPENCODE_CONFIG_DIR } from './shared.js';
11
11
  import { settingsSurfaceOf } from './settings-files.js';
12
12
  import { parseWebSearchSelection } from './config-v2.js';
13
- import { getWebSearchSource, setWebSearchSelection } from './websearch-config.js';
13
+ import { getWebSearchSource, setWarmingEnabled, setWebSearchSelection } from './websearch-config.js';
14
14
 
15
15
  export const registerOpenCodeRoutes = (app, dependencies) => {
16
16
  const {
@@ -287,6 +287,7 @@ export const registerOpenCodeRoutes = (app, dependencies) => {
287
287
  return res.json({
288
288
  providerId,
289
289
  sources: sources.sources,
290
+ config: sources.config,
290
291
  });
291
292
  } catch (error) {
292
293
  console.error('Failed to get provider sources:', error);
@@ -379,6 +380,22 @@ export const registerOpenCodeRoutes = (app, dependencies) => {
379
380
  }
380
381
  });
381
382
 
383
+ // Session warming (`warming` in OpenCode config), written like the web
384
+ // search choice above. Settings reads the effective value from OpenCode.
385
+ app.put('/api/config/warming', (req, res) => {
386
+ const enabled = req.body?.enabled;
387
+ if (typeof enabled !== 'boolean') {
388
+ return res.status(400).json({ error: 'enabled must be a boolean' });
389
+ }
390
+ try {
391
+ const result = setWarmingEnabled(enabled);
392
+ return res.json({ success: true, changed: result.changed });
393
+ } catch (error) {
394
+ console.error('Failed to save session warming:', error);
395
+ return res.status(500).json({ error: error.message || 'Failed to save session warming' });
396
+ }
397
+ });
398
+
382
399
  app.delete('/api/provider/:providerId/auth', async (req, res) => {
383
400
  try {
384
401
  const { providerId } = req.params;
@@ -528,6 +545,24 @@ export const registerOpenCodeRoutes = (app, dependencies) => {
528
545
  return res.status(413).json({ error: `Content exceeds maximum size of ${MAX_BEHAVIOR_PROMPT_SIZE} bytes` });
529
546
  }
530
547
 
548
+ // `expectedContent` is what the editor loaded (null: no file). A file
549
+ // changed on disk since then is not overwritten with the stale copy.
550
+ if (req.body && Object.prototype.hasOwnProperty.call(req.body, 'expectedContent')) {
551
+ const expected = req.body.expectedContent;
552
+ let current = null;
553
+ try {
554
+ current = await fs.promises.readFile(AGENTS_MD_PATH, 'utf8');
555
+ } catch (error) {
556
+ if (error?.code !== 'ENOENT') throw error;
557
+ }
558
+ if (current !== expected) {
559
+ return res.status(409).json({
560
+ error: 'AGENTS.md changed on disk since it was loaded',
561
+ code: 'AGENTS_MD_CONFLICT',
562
+ });
563
+ }
564
+ }
565
+
531
566
  // Ensure parent directory exists
532
567
  const parentDir = path.dirname(AGENTS_MD_PATH);
533
568
  try {
@@ -23,6 +23,8 @@ export const createServerUtilsRuntime = (dependencies) => {
23
23
  clearLastOpenCodeError,
24
24
  getLoginShellPath,
25
25
  getArchivedSessions = null,
26
+ getMergeSpaceSessionList = null,
27
+ getSpaceEventHub = null,
26
28
  getStoredSessionMetadata = null,
27
29
  } = dependencies;
28
30
 
@@ -221,6 +223,9 @@ export const createServerUtilsRuntime = (dependencies) => {
221
223
  getUiNotificationClients,
222
224
  getArchivedSessions,
223
225
  getStoredSessionMetadata,
226
+ // Read when the proxy is set up, after `main` decided whether the spaces host exists.
227
+ mergeSpaceSessionList: typeof getMergeSpaceSessionList === 'function' ? getMergeSpaceSessionList() : null,
228
+ spaceEventHub: typeof getSpaceEventHub === 'function' ? getSpaceEventHub() : null,
224
229
  });
225
230
  };
226
231
 
@@ -65,6 +65,7 @@ export const createSettingsHelpers = (dependencies) => {
65
65
  const SIDEBAR_PROJECT_DISPLAY_MODE_VALUES = new Set(['all', 'single']);
66
66
  const SIDEBAR_VIEW_MODE_VALUES = new Set(['projects', 'timeline']);
67
67
  const SIDEBAR_PROJECT_SORT_ORDER_VALUES = new Set(['manual', 'a-z', 'z-a', 'date-added', 'recent']);
68
+ const SIDEBAR_WORKTREE_SORT_ORDER_VALUES = new Set(['recent', 'manual', 'a-z']);
68
69
  const HIDDEN_MODELS_MAX = 1024;
69
70
  const RECENT_EFFORTS_MAX_KEYS = 128;
70
71
  const RECENT_EFFORTS_MAX_VARIANTS_PER_KEY = 5;
@@ -302,6 +303,9 @@ export const createSettingsHelpers = (dependencies) => {
302
303
  if (SIDEBAR_PROJECT_SORT_ORDER_VALUES.has(candidate.sidebarProjectSortOrder)) {
303
304
  result.sidebarProjectSortOrder = candidate.sidebarProjectSortOrder;
304
305
  }
306
+ if (SIDEBAR_WORKTREE_SORT_ORDER_VALUES.has(candidate.sidebarWorktreeSortOrder)) {
307
+ result.sidebarWorktreeSortOrder = candidate.sidebarWorktreeSortOrder;
308
+ }
305
309
  if (typeof candidate.sidebarShowRecentSection === 'boolean') {
306
310
  result.sidebarShowRecentSection = candidate.sidebarShowRecentSection;
307
311
  }
@@ -607,6 +611,12 @@ export const createSettingsHelpers = (dependencies) => {
607
611
  if (typeof candidate.agentMemoryToolEnabled === 'boolean') {
608
612
  result.agentMemoryToolEnabled = candidate.agentMemoryToolEnabled;
609
613
  }
614
+ if (typeof candidate.agentNotifyToolEnabled === 'boolean') {
615
+ result.agentNotifyToolEnabled = candidate.agentNotifyToolEnabled;
616
+ }
617
+ if (typeof candidate.isolatedSpacesEnabled === 'boolean') {
618
+ result.isolatedSpacesEnabled = candidate.isolatedSpacesEnabled;
619
+ }
610
620
  if (typeof candidate.openCodeUpdateToastDismissedVersion === 'string') {
611
621
  const version = candidate.openCodeUpdateToastDismissedVersion.trim();
612
622
  result.openCodeUpdateToastDismissedVersion = version.slice(0, VERSION_STRING_MAX_LENGTH);
@@ -764,13 +764,13 @@ describe('settings registry gate', () => {
764
764
  desktopLanAccessEnabled: true, desktopKeepAwakeEnabled: true, desktopMinimizeToTrayEnabled: true, desktopMacMenuBarEnabled: true,
765
765
  desktopUiPassword: 'secret', githubClientId: 'client', githubScopes: 'repo', skillCatalogs: [{ id: 'c', label: 'C', source: 'https://x' }],
766
766
  defaultGitIdentityId: 'global', permissionAutoAccept: { sessions: { s: true }, revision: 1 },
767
- agentControlToolEnabled: true, agentWebToolEnabled: true, browserProvider: 'builtin', agentMemoryToolEnabled: true, openCodeUpdateToastDismissedVersion: '1.0.0',
767
+ agentControlToolEnabled: true, agentWebToolEnabled: true, browserProvider: 'builtin', agentMemoryToolEnabled: true, agentNotifyToolEnabled: true, isolatedSpacesEnabled: true, openCodeUpdateToastDismissedVersion: '1.0.0',
768
768
  autoDeleteEnabled: true, autoDeleteAfterDays: 30, sessionRetentionOnlyArchived: false, sessionRetentionAction: 'archive', terminalShell: 'zsh', terminalLoginShells: ['zsh'],
769
769
  openInAppId: 'vscode', dictationEnabled: true, sttProvider: 'local', sttServerUrl: 'http://localhost:8001/v1', sttModel: 'm', sttLocalModel: 'm', sttLanguage: 'en',
770
770
  tunnelProvider: 'cloudflare', tunnelMode: 'quick', tunnelBootstrapTtlMs: 600000, tunnelSessionTtlMs: 86400000, managedLocalTunnelConfigPath: '/tmp/x',
771
771
  managedRemoteTunnelHostname: 'x.example', managedRemoteTunnelToken: 'token', managedRemoteTunnelPresets: [{ id: 'a', name: 'A', hostname: 'a.example' }],
772
772
  managedRemoteTunnelSelectedPresetId: 'a', managedRemoteTunnelPresetTokens: { a: 'token' },
773
- sidebarProjectDisplayMode: 'all', sidebarViewMode: 'timeline', sidebarProjectSortOrder: 'manual', sidebarShowRecentSection: true,
773
+ sidebarProjectDisplayMode: 'all', sidebarViewMode: 'timeline', sidebarProjectSortOrder: 'manual', sidebarWorktreeSortOrder: 'recent', sidebarShowRecentSection: true,
774
774
  workStatusPanelEnabled: true, workStatusHiddenSections: ['mcp'], workStatusHiddenSectionsExplicit: true, workStatusSectionOrder: ['mcp', 'session'],
775
775
  showReasoningTraces: true, streamingAutoFollowEnabled: true, collapsibleThinkingBlocks: true, showTextJustificationActivity: true,
776
776
  chatRenderMode: 'live', activityRenderMode: 'summary', mermaidRenderingMode: 'svg', userMessageRenderingMode: 'markdown', collapsibleUserMessages: true,
@@ -123,9 +123,15 @@
123
123
  "browserProvider": {
124
124
  "scope": "instance"
125
125
  },
126
+ "agentNotifyToolEnabled": {
127
+ "scope": "instance"
128
+ },
126
129
  "agentMemoryToolEnabled": {
127
130
  "scope": "instance"
128
131
  },
132
+ "isolatedSpacesEnabled": {
133
+ "scope": "instance"
134
+ },
129
135
  "agentMemoryFeatureAvailable": {
130
136
  "scope": "instance",
131
137
  "computed": true
@@ -222,6 +228,9 @@
222
228
  "sidebarProjectSortOrder": {
223
229
  "scope": "profile"
224
230
  },
231
+ "sidebarWorktreeSortOrder": {
232
+ "scope": "profile"
233
+ },
225
234
  "sidebarShowRecentSection": {
226
235
  "scope": "profile"
227
236
  },
@@ -674,6 +683,14 @@
674
683
  "scope": "device",
675
684
  "local": true
676
685
  },
686
+ "diffFileListMode": {
687
+ "scope": "device",
688
+ "local": true
689
+ },
690
+ "diffFileTreeWidth": {
691
+ "scope": "device",
692
+ "local": true
693
+ },
677
694
  "linearIssueListStatus": {
678
695
  "scope": "device",
679
696
  "local": true
@@ -45,6 +45,7 @@ const MANAGED_PLUGIN_SETTINGS_KEYS = new Set([
45
45
  'agentControlToolEnabled',
46
46
  'agentWebToolEnabled',
47
47
  'agentMemoryToolEnabled',
48
+ 'agentNotifyToolEnabled',
48
49
  ]);
49
50
 
50
51
  export const createSettingsRuntime = (deps) => {
@@ -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
+ });
@@ -40,6 +40,7 @@ export const createGracefulShutdownRuntime = (dependencies) => {
40
40
  getDictationRuntime,
41
41
  getRelayService,
42
42
  getRelayReconcileTimer,
43
+ getSpacesHost = () => null,
43
44
  } = dependencies;
44
45
 
45
46
  let shutdownPromise = null;
@@ -78,6 +79,8 @@ export const createGracefulShutdownRuntime = (dependencies) => {
78
79
  () => clearInterval(getRelayReconcileTimer()),
79
80
  () => getGuestSurfaceRuntime()?.stop(),
80
81
  () => getRealtimeProxyRuntime()?.stop(),
82
+ // The isolated-spaces host, when the switch is on: its connections into spaces end here.
83
+ () => getSpacesHost()?.close(),
81
84
  () => getRelayService()?.stop(),
82
85
  () => getDictationRuntime()?.stop(),
83
86
  () => openCodeWatcherRuntime.stop(),