@vantran-se/openchamber-web 2.0.3 → 2.0.4

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 (93) 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-DZF8ntaq.js} +1 -1
  7. package/dist/assets/{DiffView-CX3wAX2S.js → DiffView-D2p4Ar0H.js} +1 -1
  8. package/dist/assets/{FilesView-OL3CON3r.js → FilesView-BeTtbsip.js} +1 -1
  9. package/dist/assets/{GitView-DKIjpnu4.js → GitView-DwQwqDvu.js} +1 -1
  10. package/dist/assets/{GuestAttachDialog-BIa9jCfL.js → GuestAttachDialog-CoXM0gh_.js} +1 -1
  11. package/dist/assets/{GuestSurfacePane-B_cqX5I5.js → GuestSurfacePane-DIJvkkM-.js} +1 -1
  12. package/dist/assets/{JsonTreeView-CtI1Mqwo.js → JsonTreeView-Cq_27v0y.js} +1 -1
  13. package/dist/assets/{LinearIssuesView-BD2zLu-r.js → LinearIssuesView-CsSSRMhn.js} +1 -1
  14. package/dist/assets/{MarkdownImageGallery-DFiTFZgj.js → MarkdownImageGallery-CY0dWTaE.js} +1 -1
  15. package/dist/assets/{MarkdownRendererImpl-DZm5sf1W.js → MarkdownRendererImpl-ILdXzLrt.js} +1 -1
  16. package/dist/assets/{OnboardingScreen-B8TxloXZ.js → OnboardingScreen-ClatMQAX.js} +1 -1
  17. package/dist/assets/{PatchDiff--Civs1xY.js → PatchDiff-DOwlrIK9.js} +1 -1
  18. package/dist/assets/{PierreDiffViewer-BlDmw3-A.js → PierreDiffViewer-CrcGqqD_.js} +1 -1
  19. package/dist/assets/{PlanView-DyH2SyCP.js → PlanView-BGLsMgQk.js} +1 -1
  20. package/dist/assets/{PluginPane-BibJit86.js → PluginPane-B39JEUL8.js} +1 -1
  21. package/dist/assets/{SettingsView-h-VpVO47.js → SettingsView-13B2_G-A.js} +1 -1
  22. package/dist/assets/{SettingsWindow-y9WpZqK6.js → SettingsWindow-B0BAy-f9.js} +1 -1
  23. package/dist/assets/{SubmoduleDiffSummary-DeFmhPFZ.js → SubmoduleDiffSummary-fcdDzCSW.js} +1 -1
  24. package/dist/assets/{ToolOutputDialog-8DmF7_PZ.js → ToolOutputDialog-B7xaUz2H.js} +1 -1
  25. package/dist/assets/{ToolPartDiffPreview-Ca15DKE1.js → ToolPartDiffPreview-CkWCaUmZ.js} +1 -1
  26. package/dist/assets/{VirtualizedFileDiff-BfEBad09.js → VirtualizedFileDiff-DONC4eI2.js} +1 -1
  27. package/dist/assets/{WalkthroughView-Cj3K8Xz6.js → WalkthroughView-CXDImOXW.js} +1 -1
  28. package/dist/assets/{appThemeRegistry-DAdawJZn.js → appThemeRegistry-Br2TLSlb.js} +1 -1
  29. package/dist/assets/{commitAndPush-BIPfOMXY.js → commitAndPush-CcI4ol7c.js} +1 -1
  30. package/dist/assets/{computeVirtualFileMetrics-RF-KjfUH.js → computeVirtualFileMetrics-_-20umCs.js} +1 -1
  31. package/dist/assets/{de-BqCNGROu.js → de-BtYnUgII.js} +1 -1
  32. package/dist/assets/{es-BTPpTRgN.js → es-crP5qPye.js} +1 -1
  33. package/dist/assets/{fr-hxbzMdAg.js → fr-CLSRuALk.js} +1 -1
  34. package/dist/assets/{index-CL7ZQ94-.js → index-OKDYjcMF.js} +1 -1
  35. package/dist/assets/{isDiffPlainText-D2yJ9fO6.js → isDiffPlainText-D77M6q1f.js} +1 -1
  36. package/dist/assets/{isFilePlainText-BwMEq1Ey.js → isFilePlainText-C54B4Mns.js} +1 -1
  37. package/dist/assets/{ja-_zLwwYiy.js → ja-SZ1-ucLV.js} +1 -1
  38. package/dist/assets/{ko-CFn3Dzhc.js → ko-HthbT_Ve.js} +1 -1
  39. package/dist/assets/{main-BLJaug9I.js → main-BJklC7Cl.js} +4 -4
  40. package/dist/assets/{main-quLSA7jc.js → main-CGEvLMd2.js} +2 -2
  41. package/dist/assets/{markdownCore-DS9FdwrI.js → markdownCore-BzQuETm0.js} +1 -1
  42. package/dist/assets/{miniChat-D-lJJibr.js → miniChat-BSxkRQLj.js} +2 -2
  43. package/dist/assets/{mobile-VTtphbW6.js → mobile-C5KRTNOB.js} +2 -2
  44. package/dist/assets/{patchFileDiff-D9iBcTEi.js → patchFileDiff-DGw2KryM.js} +1 -1
  45. package/dist/assets/{pl-DFHE7xac.js → pl-C8eYfjL1.js} +1 -1
  46. package/dist/assets/{pt-BR-CUjZF179.js → pt-BR-cYOrZdxS.js} +1 -1
  47. package/dist/assets/{renderElectronMiniChatApp-DF7ch5eM.js → renderElectronMiniChatApp-CnkAQV6o.js} +2 -2
  48. package/dist/assets/{renderMobileApp-CNQhdXsi.js → renderMobileApp-CMxjwisj.js} +3 -3
  49. package/dist/assets/{runtimeEndpointReset-DeaERc0M.js → runtimeEndpointReset-C45jrFq4.js} +3 -3
  50. package/dist/assets/{scheduledTasksApi-BD6Vx9pu.js → scheduledTasksApi-CkK1F9s2.js} +1 -1
  51. package/dist/assets/{shikiHighlight-_HeCCHx1.js → shikiHighlight-4O0ZM14D.js} +1 -1
  52. package/dist/assets/{textMateThemeFromAppTheme-C95XdFGj.js → textMateThemeFromAppTheme-yNq7b0DZ.js} +1 -1
  53. package/dist/assets/{tr-BB6sA_zH.js → tr-B4IQ5t-T.js} +1 -1
  54. package/dist/assets/{uk-CB4o8g-o.js → uk-DCoXG6ve.js} +1 -1
  55. package/dist/assets/{useAppFontEffects-ZM9ij19-.js → useAppFontEffects-Bzyszv2N.js} +9 -9
  56. package/dist/assets/{useInlineCommentController-CkfxMJPy.js → useInlineCommentController-zOf6ORAq.js} +1 -1
  57. package/dist/assets/{useRootScrollLock-BtiAC1fj.js → useRootScrollLock-DCaZ4BWi.js} +2 -2
  58. package/dist/assets/{vendor-codemirror-language-data-U-lXzXNK.js → vendor-codemirror-language-data-6cEFPNUR.js} +2 -2
  59. package/dist/assets/{web-components-C583Pmfm.js → web-components-BEo4-ISu.js} +1 -1
  60. package/dist/assets/{zh-CN-BRyDuWhc.js → zh-CN-BXBoxsFk.js} +1 -1
  61. package/dist/assets/{zh-TW-BDGbWA1Q.js → zh-TW-C6oelpnL.js} +1 -1
  62. package/dist/index.html +1 -1
  63. package/dist/mini-chat.html +1 -1
  64. package/dist/mobile.html +1 -1
  65. package/package.json +1 -1
  66. package/server/index.d.ts +2 -0
  67. package/server/index.js +114 -52
  68. package/server/lib/agent-tool/DOCUMENTATION.md +47 -127
  69. package/server/lib/agent-tool/runtime.js +66 -47
  70. package/server/lib/agent-tool/runtime.test.js +2 -0
  71. package/server/lib/event-stream/DOCUMENTATION.md +2 -2
  72. package/server/lib/event-stream/global-hub.js +22 -6
  73. package/server/lib/event-stream/global-hub.test.js +93 -2
  74. package/server/lib/event-stream/rebind.test.js +1 -0
  75. package/server/lib/event-stream/runtime.js +1 -2
  76. package/server/lib/opencode/DOCUMENTATION.md +23 -23
  77. package/server/lib/opencode/lifecycle.js +126 -0
  78. package/server/lib/opencode/lifecycle.test.js +228 -1
  79. package/server/lib/opencode/network-runtime.js +16 -7
  80. package/server/lib/opencode/network-runtime.test.js +29 -1
  81. package/server/lib/opencode/proxy.js +15 -14
  82. package/server/lib/opencode/server-startup-runtime.js +25 -0
  83. package/server/lib/opencode/server-startup-runtime.test.js +67 -1
  84. package/server/lib/opencode/shared-service-runtime.js +155 -0
  85. package/server/lib/opencode/shared-service-runtime.test.js +180 -0
  86. package/server/lib/opencode/shutdown-runtime.js +11 -5
  87. package/server/lib/opencode/shutdown-runtime.test.js +77 -2
  88. package/server/lib/opencode/startup-pipeline-runtime.js +5 -4
  89. package/server/lib/opencode/startup-pipeline-runtime.test.js +18 -7
  90. package/server/lib/opencode/watcher.test.js +54 -0
  91. package/server/opencode-proxy.test.js +158 -0
  92. package/server/proxy-headers.js +8 -2
  93. package/server/proxy-headers.test.js +20 -0
@@ -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
+ });
@@ -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,
@@ -66,7 +68,9 @@ export const createGracefulShutdownRuntime = (dependencies) => {
66
68
  beginGuestServiceShutdown();
67
69
  syncToHmrState();
68
70
  console.log('Starting graceful shutdown...');
69
- const exitProcess = typeof options.exitProcess === 'boolean' ? options.exitProcess : getExitOnShutdown();
71
+ const exitProcess = options.exitProcess === true || options.exitProcess === false
72
+ ? options.exitProcess
73
+ : getExitOnShutdown();
70
74
 
71
75
  // Both embedded stop() and daemon exits use this sequence. Close admission
72
76
  // synchronously above, then stop viewers before draining their services.
@@ -77,6 +81,7 @@ export const createGracefulShutdownRuntime = (dependencies) => {
77
81
  () => getRelayService()?.stop(),
78
82
  () => getDictationRuntime()?.stop(),
79
83
  () => openCodeWatcherRuntime.stop(),
84
+ () => globalEventHub.stop(),
80
85
  () => sessionRuntime.dispose(),
81
86
  () => sessionAssistRuntime?.stop?.(),
82
87
  () => sessionGoalRuntime?.stop?.(),
@@ -118,7 +123,8 @@ export const createGracefulShutdownRuntime = (dependencies) => {
118
123
  }
119
124
  }
120
125
 
121
- if (!shouldSkipOpenCodeStop()) {
126
+ const connectionKind = getOpenCodeConnectionKind();
127
+ if (connectionKind === 'managed-owned') {
122
128
  const portToKill = getOpenCodePort();
123
129
  const openCodeProcess = getOpenCodeProcess();
124
130
 
@@ -136,8 +142,8 @@ export const createGracefulShutdownRuntime = (dependencies) => {
136
142
  if (!(await waitForPortRelease(portToKill, 5000))) {
137
143
  console.warn(`Timed out waiting for OpenCode port ${portToKill} to be released during shutdown`);
138
144
  }
139
- } else {
140
- console.log('Skipping OpenCode shutdown (external server)');
145
+ } else if (connectionKind === 'shared-local') {
146
+ await disposeSharedOpenCodeService();
141
147
  }
142
148
 
143
149
  const server = getServer();
@@ -16,18 +16,20 @@ const createRuntime = (server, overrides = {}) => {
16
16
  openCodeWatcherRuntime: { stop: vi.fn() },
17
17
  sessionRuntime: { dispose: vi.fn() },
18
18
  scheduledTasksRuntime: { stop: vi.fn() },
19
+ globalEventHub: { stop: vi.fn() },
19
20
  getHealthCheckInterval: () => null,
20
21
  clearHealthCheckInterval: vi.fn(),
21
22
  getTerminalRuntime: () => null,
22
23
  setTerminalRuntime: vi.fn(),
23
24
  getMessageStreamRuntime: () => null,
24
25
  setMessageStreamRuntime: vi.fn(),
25
- shouldSkipOpenCodeStop: () => true,
26
+ getOpenCodeConnectionKind: () => 'explicit-external',
26
27
  getOpenCodePort: () => null,
27
28
  getOpenCodeProcess: () => null,
28
29
  setOpenCodeProcess: vi.fn(),
29
30
  killProcessOnPort: vi.fn(),
30
31
  waitForPortRelease: vi.fn(async () => true),
32
+ disposeSharedOpenCodeService: vi.fn(async () => {}),
31
33
  getServer: () => server,
32
34
  getUiAuthController: () => null,
33
35
  setUiAuthController: vi.fn(),
@@ -151,7 +153,7 @@ describe('graceful shutdown runtime', () => {
151
153
  throw new Error('fixture cleanup failure');
152
154
  },
153
155
  getTerminalRuntime: () => ({ shutdown: terminalShutdown }),
154
- shouldSkipOpenCodeStop: () => false,
156
+ getOpenCodeConnectionKind: () => 'managed-owned',
155
157
  getOpenCodeProcess: () => ({ close: processClose }),
156
158
  });
157
159
  await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
@@ -187,6 +189,70 @@ describe('graceful shutdown runtime', () => {
187
189
  }
188
190
  });
189
191
 
192
+ it('only managed-owned closes and kills OpenCode before waiting for its port', async () => {
193
+ const close = vi.fn(async () => {});
194
+ const killProcessOnPort = vi.fn();
195
+ const waitForPortRelease = vi.fn(async () => true);
196
+ const disposeSharedOpenCodeService = vi.fn(async () => {});
197
+ const runtime = createRuntime(null, {
198
+ getOpenCodeConnectionKind: () => 'managed-owned',
199
+ getOpenCodePort: () => 4096,
200
+ getOpenCodeProcess: () => ({ close }),
201
+ killProcessOnPort,
202
+ waitForPortRelease,
203
+ disposeSharedOpenCodeService,
204
+ });
205
+
206
+ await runtime.gracefulShutdown({ exitProcess: false });
207
+
208
+ expect(close).toHaveBeenCalledOnce();
209
+ expect(killProcessOnPort).toHaveBeenCalledWith(4096);
210
+ expect(waitForPortRelease).toHaveBeenCalledWith(4096, 5000);
211
+ expect(disposeSharedOpenCodeService).not.toHaveBeenCalled();
212
+ });
213
+
214
+ it('shared-local disposes connection state without touching the shared service process', async () => {
215
+ const close = vi.fn();
216
+ const killProcessOnPort = vi.fn();
217
+ const waitForPortRelease = vi.fn();
218
+ const disposeSharedOpenCodeService = vi.fn(async () => {});
219
+ const runtime = createRuntime(null, {
220
+ getOpenCodeConnectionKind: () => 'shared-local',
221
+ getOpenCodeProcess: () => ({ close }),
222
+ killProcessOnPort,
223
+ waitForPortRelease,
224
+ disposeSharedOpenCodeService,
225
+ });
226
+
227
+ await runtime.gracefulShutdown({ exitProcess: false });
228
+
229
+ expect(disposeSharedOpenCodeService).toHaveBeenCalledOnce();
230
+ expect(close).not.toHaveBeenCalled();
231
+ expect(killProcessOnPort).not.toHaveBeenCalled();
232
+ expect(waitForPortRelease).not.toHaveBeenCalled();
233
+ });
234
+
235
+ it('explicit-external leaves OpenCode and shared state untouched', async () => {
236
+ const close = vi.fn();
237
+ const killProcessOnPort = vi.fn();
238
+ const waitForPortRelease = vi.fn();
239
+ const disposeSharedOpenCodeService = vi.fn();
240
+ const runtime = createRuntime(null, {
241
+ getOpenCodeConnectionKind: () => 'explicit-external',
242
+ getOpenCodeProcess: () => ({ close }),
243
+ killProcessOnPort,
244
+ waitForPortRelease,
245
+ disposeSharedOpenCodeService,
246
+ });
247
+
248
+ await runtime.gracefulShutdown({ exitProcess: false });
249
+
250
+ expect(close).not.toHaveBeenCalled();
251
+ expect(killProcessOnPort).not.toHaveBeenCalled();
252
+ expect(waitForPortRelease).not.toHaveBeenCalled();
253
+ expect(disposeSharedOpenCodeService).not.toHaveBeenCalled();
254
+ });
255
+
190
256
  it('stops guest services during shutdown', async () => {
191
257
  const server = {
192
258
  close: vi.fn((callback) => {
@@ -255,6 +321,15 @@ describe('graceful shutdown runtime', () => {
255
321
  expect(vi.getTimerCount()).toBe(0);
256
322
  });
257
323
 
324
+ it('stops the application-owned global event hub during embedded shutdown', async () => {
325
+ const globalEventHub = { stop: vi.fn() };
326
+ const runtime = createRuntime(null, { globalEventHub });
327
+
328
+ await runtime.gracefulShutdown({ exitProcess: false });
329
+
330
+ expect(globalEventHub.stop).toHaveBeenCalledOnce();
331
+ });
332
+
258
333
  it('isolates failed viewer and relay cleanup and still drains guests and exits', async () => {
259
334
  const stop = vi.fn(() => { throw new Error('stop failed'); });
260
335
  const stopAllGuestServices = vi.fn(async () => {});
@@ -34,7 +34,6 @@ export const createStartupPipelineRuntime = (dependencies) => {
34
34
  terminalRebindWindowMs,
35
35
  terminalMaxRebindsPerWindow,
36
36
  setupProxy,
37
- scheduleOpenCodeApiDetection,
38
37
  bootstrapOpenCodeAtStartup,
39
38
  staticRoutesRuntime,
40
39
  process,
@@ -132,13 +131,15 @@ export const createStartupPipelineRuntime = (dependencies) => {
132
131
  bindHost,
133
132
  startupTunnelRequest,
134
133
  onTunnelReady,
134
+ afterListening: async ({ activePort }) => {
135
+ tunnelRuntimeContext.setActivePort(activePort);
136
+ await bootstrapOpenCodeAtStartup();
137
+ },
135
138
  });
136
139
  recordStartupPerformance('web.listener.ready', {
137
140
  durationMs: performance.now() - pipelineStartedAt,
138
141
  });
139
- tunnelRuntimeContext.setActivePort(startupResult.activePort);
140
- scheduleOpenCodeApiDetection();
141
- void bootstrapOpenCodeAtStartup();
142
+
142
143
 
143
144
  serverStartupRuntime.attachProcessHandlers({ attachSignals });
144
145
 
@@ -3,23 +3,27 @@ import { describe, expect, it, vi } from 'vitest';
3
3
  import { createStartupPipelineRuntime } from './startup-pipeline-runtime.js';
4
4
 
5
5
  describe('startup pipeline runtime', () => {
6
- it('publishes the listening port before bootstrapping managed OpenCode', async () => {
6
+ it('publishes the listening port before awaiting OpenCode bootstrap', async () => {
7
7
  const order = [];
8
+ let releaseBootstrap;
9
+ const bootstrapGate = new Promise((resolve) => { releaseBootstrap = resolve; });
8
10
  const runtime = createStartupPipelineRuntime({
9
11
  createTerminalRuntime: () => ({}),
10
12
  createDictationRuntime: () => ({}),
11
13
  createMessageStreamWsRuntime: () => ({}),
12
14
  createServerStartupRuntime: () => ({
13
15
  resolveBindHost: () => '127.0.0.1',
14
- startListeningAndMaybeTunnel: async () => {
16
+ startListeningAndMaybeTunnel: async ({ afterListening }) => {
15
17
  order.push('listen');
18
+ await afterListening({ activePort: 3901, bindHost: '127.0.0.1' });
19
+ order.push('ready');
16
20
  return { activePort: 3901 };
17
21
  },
18
22
  attachProcessHandlers: vi.fn(),
19
23
  }),
20
24
  });
21
25
 
22
- await runtime.run({
26
+ const run = runtime.run({
23
27
  app: {},
24
28
  setupProxy: vi.fn(),
25
29
  staticRoutesRuntime: { registerStaticRoutes: vi.fn() },
@@ -27,14 +31,21 @@ describe('startup pipeline runtime', () => {
27
31
  tunnelRuntimeContext: {
28
32
  setActivePort: (port) => order.push(`port:${port}`),
29
33
  },
30
- scheduleOpenCodeApiDetection: () => order.push('detect'),
31
- bootstrapOpenCodeAtStartup: () => order.push('bootstrap'),
34
+ bootstrapOpenCodeAtStartup: async () => {
35
+ order.push('bootstrap');
36
+ await bootstrapGate;
37
+ },
32
38
  process: {},
33
39
  crypto: {},
34
- server: {},
40
+ server: { close: (callback) => callback() },
35
41
  attachSignals: false,
36
42
  });
37
43
 
38
- expect(order).toEqual(['listen', 'port:3901', 'detect', 'bootstrap']);
44
+ await Promise.resolve();
45
+ expect(order).toEqual(['listen', 'port:3901', 'bootstrap']);
46
+ releaseBootstrap();
47
+ await run;
48
+
49
+ expect(order).toEqual(['listen', 'port:3901', 'bootstrap', 'ready']);
39
50
  });
40
51
  });
@@ -3,6 +3,21 @@ import { afterEach, describe, expect, it, vi } from 'vitest';
3
3
  import { createGlobalMessageStreamHub } from '../event-stream/global-hub.js';
4
4
  import { createOpenCodeWatcherRuntime } from './watcher.js';
5
5
 
6
+ async function waitForAssertion(assertion) {
7
+ const deadline = Date.now() + 1000;
8
+ let lastError;
9
+ while (Date.now() < deadline) {
10
+ try {
11
+ assertion();
12
+ return;
13
+ } catch (error) {
14
+ lastError = error;
15
+ await new Promise((resolve) => setTimeout(resolve, 10));
16
+ }
17
+ }
18
+ throw lastError;
19
+ }
20
+
6
21
  function createSseResponse({ blocks = [], signal, holdOpen = false }) {
7
22
  const encoder = new TextEncoder();
8
23
  let index = 0;
@@ -189,6 +204,45 @@ describe('createOpenCodeWatcherRuntime', () => {
189
204
  });
190
205
  });
191
206
 
207
+ it('keeps its shared subscription across a hub rebind', async () => {
208
+ vi.spyOn(console, 'log').mockImplementation(() => {});
209
+ let generation = 0;
210
+ const payloads = [];
211
+ const globalEventHub = createGlobalMessageStreamHub({
212
+ buildOpenCodeUrl: () => `http://127.0.0.1:${generation === 0 ? 4096 : 5096}/api/event`,
213
+ getOpenCodeAuthHeaders: () => ({ Authorization: generation === 0 ? 'Basic first' : 'Basic second' }),
214
+ upstreamReconnectDelayMs: 60_000,
215
+ fetchImpl: async (_url, options) => createSseResponse({
216
+ signal: options.signal,
217
+ holdOpen: true,
218
+ blocks: [generation === 0
219
+ ? 'data: {"id":"evt-1","type":"server.connected","data":{}}\n\n'
220
+ : 'data: {"id":"evt-2","type":"session.renamed","location":{"directory":"/tmp/project"},"data":{"sessionID":"ses_1","title":"Recovered"}}\n\n'],
221
+ }),
222
+ });
223
+ const watcher = createOpenCodeWatcherRuntime({
224
+ waitForOpenCodePort: async () => {},
225
+ buildOpenCodeUrl: () => '',
226
+ getOpenCodeAuthHeaders: () => ({}),
227
+ globalEventHub,
228
+ onPayload: (payload) => payloads.push(payload),
229
+ });
230
+
231
+ try {
232
+ await watcher.start();
233
+ await waitForAssertion(() => expect(payloads.map((payload) => payload.type)).toContain('server.connected'));
234
+ generation = 1;
235
+ globalEventHub.rebind();
236
+ await waitForAssertion(() => expect(payloads).toContainEqual(expect.objectContaining({
237
+ type: 'session.updated',
238
+ properties: expect.objectContaining({ sessionID: 'ses_1' }),
239
+ })));
240
+ } finally {
241
+ watcher.stop();
242
+ globalEventHub.stop();
243
+ }
244
+ });
245
+
192
246
  it('does not stop a shared global event hub when the watcher stops', async () => {
193
247
  const events = new Set();
194
248
  const statuses = new Set();