@openchambery/web 1.19.20 → 2.0.0-beta.1

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 (189) hide show
  1. package/README.md +13 -11
  2. package/bin/cli-opencode.test.js +208 -0
  3. package/bin/cli.js +62 -9
  4. package/bin/lib/cli-startup.js +105 -2
  5. package/bin/lib/cli-startup.test.js +165 -0
  6. package/dist/assets/{AgentAvatar-NT957MuI.js → AgentAvatar-CLWWNu_Q.js} +1 -1
  7. package/dist/assets/AssistantView-BTCkm8yk.js +3 -0
  8. package/dist/assets/{DiagramView-C_JfbxNl.js → DiagramView-xuSJTnv7.js} +1 -1
  9. package/dist/assets/{FadeInOnReveal-CilRl4gc.js → FadeInOnReveal-Ov0D96d0.js} +1 -1
  10. package/dist/assets/{Icon-i5GiQRLq.js → Icon-DD-BuMfZ.js} +1 -1
  11. package/dist/assets/MarkdownRenderer-bc_p1H7l.js +2 -0
  12. package/dist/assets/{MarkdownRendererImpl-B1iUwzOz.js → MarkdownRendererImpl-SKi4oG0c.js} +1 -1
  13. package/dist/assets/{MarkstreamRendererImpl-BPtMj3-X.js → MarkstreamRendererImpl-CnkodGJW.js} +1 -1
  14. package/dist/assets/MobileDetailNavigation-Bnx59U_J.js +1 -0
  15. package/dist/assets/{MobileShareBridge-CKClHsWN.js → MobileShareBridge-BQlP4J-F.js} +1 -1
  16. package/dist/assets/MobileSurface-BXLeiZ4d.js +2 -0
  17. package/dist/assets/{MultiRunWindow-DiA24bnN.js → MultiRunWindow-ChZGob0v.js} +1 -1
  18. package/dist/assets/{OnboardingScreen-BGRLk4Wi.js → OnboardingScreen-DtQJxZG-.js} +1 -1
  19. package/dist/assets/{OverlayScrollbar-NuQI58XD.js → OverlayScrollbar-pMwxaCca.js} +1 -1
  20. package/dist/assets/SettingsView-B5I0KDTZ.js +25 -0
  21. package/dist/assets/{SettingsWindow-XlzRSS7E.js → SettingsWindow-DfnsFa8y.js} +1 -1
  22. package/dist/assets/{TerminalView-DdIaaLba.js → TerminalView-Dh9MWCpS.js} +1 -1
  23. package/dist/assets/{ToolOutputDialog-BbwuLdXU.js → ToolOutputDialog-BejmjrRk.js} +1 -1
  24. package/dist/assets/{appThemeRegistry-ChzfD7mc.js → appThemeRegistry-COGfxTFV.js} +1 -1
  25. package/dist/assets/{button-DtzFDvij.js → button-D-rATN7w.js} +1 -1
  26. package/dist/assets/{collapsible-D-nzhcyF.js → collapsible-BXRd5_Hr.js} +1 -1
  27. package/dist/assets/{context-menu-BzVSzHLG.js → context-menu-jGtH6fR9.js} +1 -1
  28. package/dist/assets/{diagram-2YWalSb5.js → diagram-DQf7Y_uR.js} +1 -1
  29. package/dist/assets/{dialog-DGV7DsZC.js → dialog-bfbvR4VH.js} +1 -1
  30. package/dist/assets/es-c52LTHHe.js +15 -0
  31. package/dist/assets/{fileMentionTouchSelection-DyjmxUEN.js → fileMentionTouchSelection-BvrNCroG.js} +1 -1
  32. package/dist/assets/{fr-DsBZeN8Y.js → fr-PisFbUE8.js} +3 -3
  33. package/dist/assets/gitApi-ButwzQ0N.js +9 -0
  34. package/dist/assets/{input-CbB2HdLg.js → input-8r6F_Fza.js} +1 -1
  35. package/dist/assets/insertionBoundaries-DGs7S5zv.js +1 -0
  36. package/dist/assets/ja-C8Ikun3Z.js +15 -0
  37. package/dist/assets/ko-ClFys5o2.js +15 -0
  38. package/dist/assets/main-CCE_4y5F.js +2 -0
  39. package/dist/assets/main-Dn9rpsgl.js +169 -0
  40. package/dist/assets/{markdownHeightCache-C9ym9Tsb.js → markdownHeightCache-1Qo6y6IJ.js} +1 -1
  41. package/dist/assets/miniChat-BpjxVqgg.js +2 -0
  42. package/dist/assets/mobile-nMJCaNq1.js +2 -0
  43. package/dist/assets/{multirun-8fkF0Ji_.js → multirun-BvSG94mW.js} +1 -1
  44. package/dist/assets/pl-CnaKWN5F.js +15 -0
  45. package/dist/assets/platform-CWTIp02q.js +4 -0
  46. package/dist/assets/{projectMeta--y6tyjrv.js → projectMeta-DOe49dM_.js} +2 -2
  47. package/dist/assets/pt-BR-s8OpfRI6.js +15 -0
  48. package/dist/assets/renderElectronMiniChatApp-R6dKh79f.js +2 -0
  49. package/dist/assets/renderMobileApp-y-S8y8v_.js +28 -0
  50. package/dist/assets/{runtimeConfig-DYWtBrkW.js → runtimeConfig-BMvqa5we.js} +1 -1
  51. package/dist/assets/{runtimeEndpointReset-BSWg99jc.js → runtimeEndpointReset-B6BxhvR0.js} +5 -5
  52. package/dist/assets/{sessionChangeSummary-BwupJF-Q.js → sessionChangeSummary-VVBlEXuh.js} +1 -1
  53. package/dist/assets/{sessionLookup-DP2X1qRR.js → sessionLookup-gh-hVRIc.js} +1 -1
  54. package/dist/assets/{sortable-tabs-strip-CfgEJfB0.js → sortable-tabs-strip-CudoSgV4.js} +1 -1
  55. package/dist/assets/{src-DZ60kHBP.css → src-BzX5UI-c.css} +1 -1
  56. package/dist/assets/{store-B6dImdlY.js → store-DJczmczv.js} +4 -4
  57. package/dist/assets/{terminalApi-xjqssKke.js → terminalApi-DiF6JmBP.js} +1 -1
  58. package/dist/assets/{textarea-BnD799LW.js → textarea-3XR8sUfy.js} +1 -1
  59. package/dist/assets/{tooltip-Djn7MSOX.js → tooltip-CQvpK-UV.js} +1 -1
  60. package/dist/assets/uk-CaX0iqkB.js +15 -0
  61. package/dist/assets/{useAppFontEffects-DWLxpWlS.js → useAppFontEffects-Cpmpo7Wb.js} +45 -45
  62. package/dist/assets/{useDesktopWindowControlsLayout-BGPR_rq6.js → useDesktopWindowControlsLayout-B1GX59-L.js} +1 -1
  63. package/dist/assets/{useEffectiveDirectory-CbV9_r1s.js → useEffectiveDirectory-CN6JoCFw.js} +1 -1
  64. package/dist/assets/{useFontPreferences-B1JYyzpK.js → useFontPreferences-DcbXQOkc.js} +1 -1
  65. package/dist/assets/useMobileNavigationStore-BVjFtXSy.js +414 -0
  66. package/dist/assets/{useSessionAutoCleanup-Dnors7hB.js → useSessionAutoCleanup-BLjymaFt.js} +1 -1
  67. package/dist/assets/{useWorkerHighlightedLines-DQqqrOBU.js → useWorkerHighlightedLines-Bqn4AiBL.js} +1 -1
  68. package/dist/assets/vendor-opencode-client-BOe2qpQb.js +12 -0
  69. package/dist/assets/{vendor-yaml-DIcAK3EC.js → vendor-yaml-C9KlJAlj.js} +37 -37
  70. package/dist/assets/zh-CN-20MYEjha.js +15 -0
  71. package/dist/assets/{zh-TW-BsOQbrwT.js → zh-TW-DiN04qTw.js} +3 -3
  72. package/dist/index.html +6 -6
  73. package/dist/mini-chat.html +6 -6
  74. package/dist/mobile.html +6 -6
  75. package/package.json +2 -2
  76. package/server/index.js +107 -0
  77. package/server/lib/assistants/DOCUMENTATION.md +18 -18
  78. package/server/lib/assistants/assign.js +2 -1
  79. package/server/lib/assistants/contact-store.js +37 -19
  80. package/server/lib/assistants/projection-history.test.js +207 -0
  81. package/server/lib/assistants/routes.js +5 -5
  82. package/server/lib/assistants/service.js +493 -123
  83. package/server/lib/assistants/service.test.js +203 -133
  84. package/server/lib/conversations/DOCUMENTATION.md +4 -4
  85. package/server/lib/conversations/service.js +86 -57
  86. package/server/lib/conversations/service.test.js +116 -100
  87. package/server/lib/event-stream/global-hub.js +2 -1
  88. package/server/lib/llm/DOCUMENTATION.md +71 -84
  89. package/server/lib/llm/catalog.js +111 -45
  90. package/server/lib/llm/catalog.test.js +121 -59
  91. package/server/lib/llm/completions.js +37 -12
  92. package/server/lib/llm/completions.test.js +45 -4
  93. package/server/lib/llm/generate.js +421 -450
  94. package/server/lib/llm/generate.test.js +553 -487
  95. package/server/lib/llm/routes.js +6 -4
  96. package/server/lib/message-queue/DOCUMENTATION.md +5 -5
  97. package/server/lib/message-queue/opencode-adapter.js +191 -104
  98. package/server/lib/message-queue/opencode-adapter.test.js +328 -58
  99. package/server/lib/message-queue/runtime.test.js +6 -6
  100. package/server/lib/message-queue/worker.js +7 -1
  101. package/server/lib/opencode/DOCUMENTATION.md +28 -5
  102. package/server/lib/opencode/config-entity-routes.js +84 -13
  103. package/server/lib/opencode/config-entity-routes.test.js +41 -10
  104. package/server/lib/opencode/ensure-cli.js +239 -0
  105. package/server/lib/opencode/ensure-cli.test.js +119 -0
  106. package/server/lib/opencode/env-runtime.js +193 -110
  107. package/server/lib/opencode/env-runtime.test.js +221 -17
  108. package/server/lib/opencode/feature-routes-runtime.js +37 -1
  109. package/server/lib/opencode/lifecycle.js +204 -70
  110. package/server/lib/opencode/lifecycle.test.js +376 -6
  111. package/server/lib/opencode/network-runtime.js +28 -16
  112. package/server/lib/opencode/network-runtime.test.js +68 -4
  113. package/server/lib/opencode/opencode2-pin.js +139 -0
  114. package/server/lib/opencode/opencode2-pin.test.js +87 -0
  115. package/server/lib/opencode/opencode2-sidecar.e2e.test.js +203 -0
  116. package/server/lib/opencode/plugin-compatibility.js +15 -0
  117. package/server/lib/opencode/plugin-compatibility.test.js +21 -0
  118. package/server/lib/opencode/plugins.js +4 -3
  119. package/server/lib/opencode/plugins.test.js +12 -0
  120. package/server/lib/opencode/proxy.js +153 -16
  121. package/server/lib/opencode/proxy.test.js +120 -0
  122. package/server/lib/opencode/routes.js +25 -32
  123. package/server/lib/opencode/routes.test.js +51 -52
  124. package/server/lib/opencode/server-utils-runtime.js +2 -0
  125. package/server/lib/opencode/skill-routes.js +6 -7
  126. package/server/lib/opencode/skill-routes.test.js +36 -5
  127. package/server/lib/opencode/v1-migration-gate.js +157 -0
  128. package/server/lib/opencode/v1-migration-gate.test.js +111 -0
  129. package/server/lib/opencode/v2-client.js +49 -0
  130. package/server/lib/opencode/watcher.js +2 -1
  131. package/server/lib/opencode/watcher.test.js +1 -1
  132. package/server/lib/scheduled-tasks/DOCUMENTATION.md +71 -39
  133. package/server/lib/scheduled-tasks/managed-tool-route.js +31 -1
  134. package/server/lib/scheduled-tasks/managed-tool-route.test.js +23 -0
  135. package/server/lib/scheduled-tasks/routes.js +27 -0
  136. package/server/lib/scheduled-tasks/routes.test.js +94 -0
  137. package/server/lib/scheduled-tasks/runtime.js +331 -308
  138. package/server/lib/scheduled-tasks/runtime.test.js +530 -318
  139. package/server/lib/session-goal/DOCUMENTATION.md +65 -14
  140. package/server/lib/session-goal/capability.js +57 -0
  141. package/server/lib/session-goal/capability.test.js +33 -0
  142. package/server/lib/session-goal/routes.js +231 -2
  143. package/server/lib/session-goal/routes.test.js +211 -0
  144. package/server/lib/session-goal/runtime.js +46 -4
  145. package/server/lib/session-goal/runtime.test.js +62 -1
  146. package/server/lib/session-index/sync-runtime.js +20 -17
  147. package/server/lib/session-index/sync-runtime.test.js +9 -8
  148. package/server/lib/session-metadata/DOCUMENTATION.md +41 -0
  149. package/server/lib/session-metadata/routes.js +94 -0
  150. package/server/lib/session-metadata/routes.test.js +111 -0
  151. package/server/lib/session-metadata/session-metadata-store.js +235 -0
  152. package/server/lib/session-metadata/session-metadata-store.test.js +184 -0
  153. package/server/lib/session-turn-pages/DOCUMENTATION.md +3 -3
  154. package/server/lib/session-turn-pages/reconcile.service.js +1 -1
  155. package/server/lib/session-turn-pages/reconcile.service.test.js +16 -0
  156. package/server/lib/session-turn-pages/routes.js +204 -71
  157. package/server/lib/session-turn-pages/routes.test.js +285 -0
  158. package/server/lib/session-turn-pages/service.js +6 -2
  159. package/server/lib/session-turn-pages/service.test.js +34 -0
  160. package/server/lib/small-model/DOCUMENTATION.md +5 -4
  161. package/server/lib/small-model/catalog.js +56 -6
  162. package/server/lib/small-model/catalog.test.js +47 -0
  163. package/server/lib/small-model/index.js +3 -3
  164. package/server/lib/small-model/opencode-session.js +50 -206
  165. package/server/lib/small-model/opencode-session.test.js +58 -88
  166. package/server/opencode-proxy.test.js +61 -12
  167. package/dist/assets/AssistantView-BjvfkSRS.js +0 -3
  168. package/dist/assets/MarkdownRenderer-Cg82L3fN.js +0 -2
  169. package/dist/assets/MobileDetailNavigation-s_vvUD_F.js +0 -1
  170. package/dist/assets/MobileSurface-DHpXHbJp.js +0 -2
  171. package/dist/assets/SettingsView-l0J7pfYo.js +0 -25
  172. package/dist/assets/es-rqMiRP7-.js +0 -15
  173. package/dist/assets/gitApi-CHpibtno.js +0 -7
  174. package/dist/assets/insertionBoundaries-Yd1Lorjw.js +0 -1
  175. package/dist/assets/ja-Bgr8-dOg.js +0 -15
  176. package/dist/assets/ko-B_Gxh-0l.js +0 -15
  177. package/dist/assets/main-BoRXxmB3.js +0 -2
  178. package/dist/assets/main-DCDcdV5o.js +0 -169
  179. package/dist/assets/miniChat-12fxk5KR.js +0 -2
  180. package/dist/assets/mobile-CkZem9Ad.js +0 -2
  181. package/dist/assets/pl-DW2XJGC4.js +0 -15
  182. package/dist/assets/platform-0w_Q9_ji.js +0 -2
  183. package/dist/assets/pt-BR-CIsanf4T.js +0 -15
  184. package/dist/assets/renderElectronMiniChatApp-COFc0ZDJ.js +0 -2
  185. package/dist/assets/renderMobileApp-BKzx7JL3.js +0 -28
  186. package/dist/assets/uk-BPmPEUYJ.js +0 -15
  187. package/dist/assets/useMobileNavigationStore-DrB_awgk.js +0 -413
  188. package/dist/assets/vendor-opencode-sdk-C07HQypa.js +0 -7
  189. package/dist/assets/zh-CN-R8-mDcIp.js +0 -15
@@ -9,6 +9,7 @@ vi.mock('node:child_process', () => ({
9
9
  }));
10
10
 
11
11
  const { createOpenCodeLifecycleRuntime } = await import('./lifecycle.js');
12
+ const { createOpenCodeNetworkRuntime } = await import('./network-runtime.js');
12
13
 
13
14
  const originalOpencodeBinary = process.env.OPENCODE_BINARY;
14
15
  const originalPath = process.env.PATH;
@@ -57,6 +58,7 @@ const createRuntime = (overrides = {}, stateRef = null) => {
57
58
  openCodeApiDetectionTimer: null,
58
59
  lastOpenCodeError: null,
59
60
  isOpenCodeReady: false,
61
+ v1Migration: null,
60
62
  openCodeNotReadySince: 0,
61
63
  isExternalOpenCode: false,
62
64
  isShuttingDown: false,
@@ -109,6 +111,53 @@ const createRuntime = (overrides = {}, stateRef = null) => {
109
111
  });
110
112
  };
111
113
 
114
+ const jsonResponse = (body, status = 200) => new Response(JSON.stringify(body), {
115
+ status,
116
+ headers: { 'content-type': 'application/json' },
117
+ });
118
+
119
+ const DEFAULT_V2_HEALTH = { version: '2.0.12', pid: 1, urls: [], paths: { tmp: '/tmp' } };
120
+
121
+ const stubOpenCodeFetch = (overrides = {}) => {
122
+ const fetchMock = vi.fn(async (url, init) => {
123
+ const href = String(url);
124
+ if (href.includes('/api/experimental/migration/v1')) {
125
+ const migration = overrides.migration;
126
+ if (typeof migration === 'function') {
127
+ return migration(url, init);
128
+ }
129
+ if (migration && typeof migration === 'object' && Number.isInteger(migration.httpStatus)) {
130
+ return jsonResponse(migration.body ?? {}, migration.httpStatus);
131
+ }
132
+ return jsonResponse(migration ?? { status: 'completed' });
133
+ }
134
+ if (href.includes('/api/info') || href.includes('/global/health')) {
135
+ if (overrides.reuseDefaultServe !== true && href.includes(':4096/')) {
136
+ return new Response('not found', { status: 404 });
137
+ }
138
+ const health = overrides.health;
139
+ if (typeof health === 'function') {
140
+ return health(url, init);
141
+ }
142
+ return jsonResponse(health ?? DEFAULT_V2_HEALTH);
143
+ }
144
+ return new Response('not found', { status: 404 });
145
+ });
146
+ vi.stubGlobal('fetch', fetchMock);
147
+ return fetchMock;
148
+ };
149
+
150
+ const startListeningChild = () => {
151
+ const child = createMockChild();
152
+ spawnMock.mockImplementationOnce(() => {
153
+ queueMicrotask(() => {
154
+ child.stdout.emit('data', 'server listening on http://127.0.0.1:45678\n');
155
+ });
156
+ return child;
157
+ });
158
+ return child;
159
+ };
160
+
112
161
  describe('OpenCode lifecycle', () => {
113
162
  it('launches managed OpenCode with the managed PATH', async () => {
114
163
  delete process.env.OPENCODE_BINARY;
@@ -133,6 +182,76 @@ describe('OpenCode lifecycle', () => {
133
182
  await server.close();
134
183
  });
135
184
 
185
+ it('parses a v2 server listening line without the opencode prefix', async () => {
186
+ delete process.env.OPENCODE_BINARY;
187
+ const child = createMockChild();
188
+ spawnMock.mockImplementationOnce(() => {
189
+ queueMicrotask(() => {
190
+ child.stdout.emit('data', 'server listening on http://127.0.0.1:45678\n');
191
+ });
192
+ return child;
193
+ });
194
+
195
+ const runtime = createRuntime();
196
+ const server = await runtime.startOpenCode();
197
+
198
+ expect(server.url).toBe('http://127.0.0.1:45678');
199
+
200
+ await server.close();
201
+ });
202
+
203
+ it('probes /api/info with Basic auth after the managed server is listening', async () => {
204
+ delete process.env.OPENCODE_BINARY;
205
+ const child = createMockChild();
206
+ spawnMock.mockImplementationOnce(() => {
207
+ queueMicrotask(() => {
208
+ child.stdout.emit('data', 'server listening on http://127.0.0.1:45678\n');
209
+ });
210
+ return child;
211
+ });
212
+
213
+ const fetchMock = vi.fn(async (url) => {
214
+ if (String(url).includes('/api/info')) {
215
+ return new Response(JSON.stringify(DEFAULT_V2_HEALTH), {
216
+ status: 200,
217
+ headers: { 'content-type': 'application/json' },
218
+ });
219
+ }
220
+ if (String(url).includes('/api/experimental/migration/v1')) {
221
+ return new Response(JSON.stringify({ status: 'completed' }), {
222
+ status: 200,
223
+ headers: { 'content-type': 'application/json' },
224
+ });
225
+ }
226
+ return new Response('not found', { status: 404 });
227
+ });
228
+ vi.stubGlobal('fetch', fetchMock);
229
+
230
+ const authHeaders = { Authorization: 'Basic Zml4dHVyZQ==' };
231
+ const network = createOpenCodeNetworkRuntime({
232
+ state: {
233
+ openCodePort: 45678,
234
+ openCodeBaseUrl: null,
235
+ openCodeApiPrefix: '',
236
+ openCodeApiPrefixDetected: false,
237
+ openCodeApiDetectionTimer: null,
238
+ },
239
+ getOpenCodeAuthHeaders: () => authHeaders,
240
+ });
241
+ const runtime = createRuntime({
242
+ getOpenCodeAuthHeaders: () => authHeaders,
243
+ waitForReady: network.waitForReady,
244
+ });
245
+ const server = await runtime.startOpenCode();
246
+ const healthCall = fetchMock.mock.calls.find((call) => String(call[0]).includes('/api/info'));
247
+
248
+ expect(healthCall).toBeDefined();
249
+ expect(String(healthCall[0])).toContain('/api/info');
250
+ expect(healthCall[1].headers.Authorization).toMatch(/^Basic /);
251
+
252
+ await server.close();
253
+ });
254
+
136
255
  it('prepares a fresh managed capability environment and records the spawned child pid', async () => {
137
256
  const child = createMockChild();
138
257
  spawnMock.mockImplementationOnce(() => {
@@ -298,7 +417,7 @@ describe('OpenCode lifecycle', () => {
298
417
  stateRef.current.openCodeProcess = managedChild;
299
418
  stateRef.current.openCodePort = 45678;
300
419
  stateRef.current.isExternalOpenCode = false;
301
- vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify({ healthy: true }), { status: 200 })));
420
+ stubOpenCodeFetch();
302
421
  await runtime.bootstrapOpenCodeAtStartup();
303
422
  expect(managedChild.close).toHaveBeenCalledTimes(1);
304
423
  expect(stateRef.current.openCodeProcess).toBeNull();
@@ -324,7 +443,7 @@ describe('OpenCode lifecycle', () => {
324
443
  }, stateRef);
325
444
  stateRef.current.isExternalOpenCode = true;
326
445
  stateRef.current.openCodeBaseUrl = 'http://127.0.0.1:3001';
327
- vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify({ healthy: true }), { status: 200 })));
446
+ stubOpenCodeFetch();
328
447
 
329
448
  await runtime.bootstrapOpenCodeAtStartup();
330
449
 
@@ -367,6 +486,7 @@ describe('OpenCode lifecycle', () => {
367
486
  clearResolvedOpenCodeBinary,
368
487
  }, stateRef);
369
488
  const errorLog = vi.spyOn(console, 'error').mockImplementation(() => {});
489
+ stubOpenCodeFetch();
370
490
 
371
491
  await runtime.bootstrapOpenCodeAtStartup();
372
492
  expect(spawnMock).not.toHaveBeenCalled();
@@ -378,10 +498,7 @@ describe('OpenCode lifecycle', () => {
378
498
  });
379
499
  return child;
380
500
  });
381
- vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify({ healthy: true }), {
382
- status: 200,
383
- headers: { 'content-type': 'application/json' },
384
- })));
501
+ stubOpenCodeFetch();
385
502
 
386
503
  try {
387
504
  await runtime.retryOpenCodeStartup();
@@ -393,4 +510,257 @@ describe('OpenCode lifecycle', () => {
393
510
  await stateRef.current.openCodeProcess?.close();
394
511
  }
395
512
  });
513
+
514
+ it('does not mark OpenCode ready for messages while V1 migration is required', async () => {
515
+ delete process.env.OPENCODE_BINARY;
516
+ startListeningChild();
517
+ const stateRef = {};
518
+ stubOpenCodeFetch({ migration: { status: 'required' } });
519
+ const runtime = createRuntime({}, stateRef);
520
+
521
+ const server = await runtime.startOpenCode();
522
+
523
+ expect(stateRef.current.isOpenCodeReady).toBe(false);
524
+ expect(stateRef.current.v1Migration).toMatchObject({
525
+ admitTranscript: false,
526
+ phase: 'required',
527
+ });
528
+
529
+ await server.close();
530
+ });
531
+
532
+ it('keeps the ready gate closed while waitForOpenCodeReady sees required migration', async () => {
533
+ const stateRef = {};
534
+ const runtime = createRuntime({}, stateRef);
535
+ stateRef.current.openCodePort = 45678;
536
+ stubOpenCodeFetch({
537
+ migration: { status: 'required' },
538
+ });
539
+
540
+ await expect(runtime.waitForOpenCodeReady(250, 40)).rejects.toThrow(/V1 migration is required/);
541
+ expect(stateRef.current.isOpenCodeReady).toBe(false);
542
+ expect(stateRef.current.v1Migration).toMatchObject({
543
+ admitTranscript: false,
544
+ phase: 'required',
545
+ });
546
+ });
547
+
548
+ it('marks OpenCode ready for messages after V1 migration completed', async () => {
549
+ delete process.env.OPENCODE_BINARY;
550
+ startListeningChild();
551
+ const stateRef = {};
552
+ stubOpenCodeFetch({ migration: { status: 'completed' } });
553
+ const runtime = createRuntime({}, stateRef);
554
+
555
+ const server = await runtime.startOpenCode();
556
+
557
+ expect(stateRef.current.isOpenCodeReady).toBe(true);
558
+ expect(stateRef.current.v1Migration).toMatchObject({
559
+ admitTranscript: true,
560
+ phase: 'completed',
561
+ });
562
+
563
+ await server.close();
564
+ });
565
+
566
+ it('admits messages when V1 migration is absent', async () => {
567
+ const stateRef = {};
568
+ const runtime = createRuntime({}, stateRef);
569
+ stateRef.current.openCodePort = 45678;
570
+ stubOpenCodeFetch({
571
+ migration: { httpStatus: 404, body: {} },
572
+ });
573
+
574
+ await runtime.waitForOpenCodeReady(1000, 40);
575
+
576
+ expect(stateRef.current.isOpenCodeReady).toBe(true);
577
+ expect(stateRef.current.v1Migration).toMatchObject({
578
+ admitTranscript: true,
579
+ phase: 'absent',
580
+ });
581
+ });
582
+
583
+ it('retries V1 migration error and still blocks messages until completed', async () => {
584
+ const stateRef = {};
585
+ const runtime = createRuntime({}, stateRef);
586
+ stateRef.current.openCodePort = 45678;
587
+ let migrationCalls = 0;
588
+ const fetchMock = stubOpenCodeFetch({
589
+ migration: () => {
590
+ migrationCalls += 1;
591
+ if (migrationCalls === 1) {
592
+ return jsonResponse({ status: 'error', error: 'locked' });
593
+ }
594
+ return jsonResponse({ status: 'completed' });
595
+ },
596
+ });
597
+
598
+ await runtime.waitForOpenCodeReady(2000, 40);
599
+
600
+ expect(migrationCalls).toBeGreaterThan(1);
601
+ expect(fetchMock.mock.calls.some((call) => String(call[0]).includes('/api/experimental/migration/v1'))).toBe(true);
602
+ expect(fetchMock.mock.calls.every((call) => !call[1] || call[1].method !== 'POST')).toBe(true);
603
+ expect(stateRef.current.isOpenCodeReady).toBe(true);
604
+ expect(stateRef.current.v1Migration).toMatchObject({
605
+ admitTranscript: true,
606
+ phase: 'completed',
607
+ });
608
+ });
609
+
610
+ it('publishes v1Migration on lifecycle state for health snapshot', async () => {
611
+ const stateRef = {};
612
+ const runtime = createRuntime({}, stateRef);
613
+ stateRef.current.openCodePort = 45678;
614
+ stubOpenCodeFetch({
615
+ migration: {
616
+ status: 'running',
617
+ progress: { label: 'Sessions', numerator: 2, denominator: 8 },
618
+ },
619
+ });
620
+
621
+ await expect(runtime.waitForOpenCodeReady(200, 40)).rejects.toThrow(/V1 migration is running/);
622
+ expect(stateRef.current.isOpenCodeReady).toBe(false);
623
+ expect(stateRef.current.v1Migration).toMatchObject({
624
+ admitTranscript: false,
625
+ phase: 'running',
626
+ progress: { label: 'Sessions', numerator: 2, denominator: 8 },
627
+ });
628
+ expect(stateRef.current.v1Migration.userNotice).toContain('V1 subtasks do not appear in v2');
629
+ });
630
+
631
+ it('clears v1Migration when an external OpenCode restart probe fails', async () => {
632
+ const stateRef = {};
633
+ const runtime = createRuntime({
634
+ env: {
635
+ ENV_CONFIGURED_OPENCODE_PORT: null,
636
+ ENV_CONFIGURED_OPENCODE_HOST: null,
637
+ ENV_EFFECTIVE_PORT: 45678,
638
+ ENV_CONFIGURED_OPENCODE_HOSTNAME: '127.0.0.1',
639
+ ENV_SKIP_OPENCODE_START: false,
640
+ },
641
+ }, stateRef);
642
+ stateRef.current.isExternalOpenCode = true;
643
+ stateRef.current.openCodePort = 45678;
644
+ stateRef.current.openCodeBaseUrl = 'http://127.0.0.1:45678';
645
+ stateRef.current.v1Migration = { admitTranscript: true, phase: 'completed' };
646
+ vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify({ healthy: false }), {
647
+ status: 200,
648
+ headers: { 'content-type': 'application/json' },
649
+ })));
650
+
651
+ await expect(runtime.restartOpenCode()).rejects.toThrow('External OpenCode server on port 45678 is not responding');
652
+ expect(stateRef.current.isOpenCodeReady).toBe(false);
653
+ expect(stateRef.current.v1Migration).toBeNull();
654
+ // Read-only external mount: failed re-probe keeps port + ownership; never spawn.
655
+ expect(stateRef.current.openCodePort).toBe(45678);
656
+ expect(stateRef.current.isExternalOpenCode).toBe(true);
657
+ expect(spawnMock).not.toHaveBeenCalled();
658
+ });
659
+
660
+ it('rejects healthy 1.x health bodies instead of admitting the process', async () => {
661
+ const stateRef = {};
662
+ const runtime = createRuntime({}, stateRef);
663
+ stateRef.current.openCodePort = 45678;
664
+ stubOpenCodeFetch({ health: { healthy: true, version: '1.15.0' } });
665
+
666
+ await expect(runtime.waitForOpenCodeReady(300, 40)).rejects.toThrow(/unhealthy|Timed out|1\.|version/i);
667
+ expect(stateRef.current.isOpenCodeReady).toBe(false);
668
+ });
669
+
670
+ it('rejects healthy responses with a missing version', async () => {
671
+ const stateRef = {};
672
+ const runtime = createRuntime({}, stateRef);
673
+ stateRef.current.openCodePort = 45678;
674
+ stubOpenCodeFetch({ health: { healthy: true } });
675
+
676
+ await expect(runtime.waitForOpenCodeReady(300, 40)).rejects.toThrow();
677
+ expect(stateRef.current.isOpenCodeReady).toBe(false);
678
+ });
679
+
680
+ it('health version + migration matrix: v2+completed admits, v2+running blocks, 1.x blocks', async () => {
681
+ const cases = [
682
+ { health: DEFAULT_V2_HEALTH, migration: { status: 'completed' }, admit: true },
683
+ { health: DEFAULT_V2_HEALTH, migration: { status: 'running' }, admit: false },
684
+ { health: DEFAULT_V2_HEALTH, migration: { status: 'required' }, admit: false },
685
+ { health: DEFAULT_V2_HEALTH, migration: { status: 'error', error: 'disk' }, admit: false },
686
+ { health: { healthy: true, version: '1.18.18' }, migration: { status: 'completed' }, admit: false },
687
+ ];
688
+
689
+ for (const entry of cases) {
690
+ const stateRef = {};
691
+ const runtime = createRuntime({}, stateRef);
692
+ stateRef.current.openCodePort = 45678;
693
+ stubOpenCodeFetch({ health: entry.health, migration: entry.migration });
694
+ if (entry.admit) {
695
+ await runtime.waitForOpenCodeReady(1000, 40);
696
+ expect(stateRef.current.isOpenCodeReady).toBe(true);
697
+ } else {
698
+ await expect(runtime.waitForOpenCodeReady(250, 40)).rejects.toThrow();
699
+ expect(stateRef.current.isOpenCodeReady).toBe(false);
700
+ }
701
+ }
702
+ });
703
+
704
+ it('aborts a hung migration probe with the same per-attempt signal after health', async () => {
705
+ const stateRef = {};
706
+ const runtime = createRuntime({}, stateRef);
707
+ stateRef.current.openCodePort = 45678;
708
+ let migrationStarted = 0;
709
+ let sawAbort = false;
710
+ stubOpenCodeFetch({
711
+ health: DEFAULT_V2_HEALTH,
712
+ migration: (_url, init) => {
713
+ migrationStarted += 1;
714
+ return new Promise((_resolve, reject) => {
715
+ const signal = init?.signal;
716
+ if (!signal) {
717
+ reject(new Error('migration probe missing abort signal'));
718
+ return;
719
+ }
720
+ if (signal.aborted) {
721
+ sawAbort = true;
722
+ reject(new DOMException('Aborted', 'AbortError'));
723
+ return;
724
+ }
725
+ signal.addEventListener('abort', () => {
726
+ sawAbort = true;
727
+ reject(new DOMException('Aborted', 'AbortError'));
728
+ }, { once: true });
729
+ });
730
+ },
731
+ });
732
+
733
+ // Default per-attempt health timeout is 5s; outer deadline must exceed it once
734
+ // so the hung migration is aborted by the attempt timer, not only the loop exit.
735
+ const started = Date.now();
736
+ await expect(runtime.waitForOpenCodeReady(6500, 50)).rejects.toThrow();
737
+ const elapsed = Date.now() - started;
738
+ expect(migrationStarted).toBeGreaterThan(0);
739
+ expect(sawAbort).toBe(true);
740
+ expect(elapsed).toBeLessThan(12_000);
741
+ expect(stateRef.current.isOpenCodeReady).toBe(false);
742
+ }, 15_000);
743
+
744
+ it('does not mark external skip-start ready before version and migration admit', async () => {
745
+ const stateRef = {};
746
+ const runtime = createRuntime({
747
+ env: {
748
+ ENV_CONFIGURED_OPENCODE_PORT: null,
749
+ ENV_CONFIGURED_OPENCODE_HOST: { origin: 'http://127.0.0.1:3001' },
750
+ ENV_EFFECTIVE_PORT: 3001,
751
+ ENV_CONFIGURED_OPENCODE_HOSTNAME: '127.0.0.1',
752
+ ENV_SKIP_OPENCODE_START: true,
753
+ },
754
+ syncFromHmrState: vi.fn(),
755
+ }, stateRef);
756
+ // Reject health so bootstrap's waitForOpenCodeReady fails quickly without a
757
+ // 20s migration spin, while still proving skip-start never pre-admits ready.
758
+ stubOpenCodeFetch({ health: { healthy: true, version: '1.15.0' } });
759
+
760
+ await runtime.bootstrapOpenCodeAtStartup();
761
+
762
+ expect(stateRef.current.isExternalOpenCode).toBe(true);
763
+ expect(stateRef.current.isOpenCodeReady).toBe(false);
764
+ expect(stateRef.current.openCodePort).toBe(3001);
765
+ }, 25_000);
396
766
  });
@@ -1,3 +1,5 @@
1
+ import { evaluateOpenCodeHealthBody } from './opencode2-pin.js';
2
+
1
3
  export const createOpenCodeNetworkRuntime = (deps) => {
2
4
  const {
3
5
  state,
@@ -41,26 +43,29 @@ export const createOpenCodeNetworkRuntime = (deps) => {
41
43
 
42
44
  const waitForReady = async (url, timeoutMs = 10000) => {
43
45
  const start = Date.now();
46
+ const baseUrl = url.replace(/\/+$/, '');
44
47
  while (Date.now() - start < timeoutMs) {
45
48
  let timeout = null;
46
49
  try {
47
50
  const controller = new AbortController();
48
51
  timeout = setTimeout(() => controller.abort(), 3000);
49
- const response = await fetch(`${url.replace(/\/+$/, '')}/global/health`, {
50
- method: 'GET',
51
- headers: {
52
- Accept: 'application/json',
53
- ...getOpenCodeAuthHeaders(),
54
- },
55
- signal: controller.signal,
56
- });
57
- clearTimeout(timeout);
58
- timeout = null;
59
-
60
- if (response.ok) {
61
- const body = await response.json().catch(() => null);
62
- if (body?.healthy === true) {
63
- return true;
52
+ const headers = {
53
+ Accept: 'application/json',
54
+ ...getOpenCodeAuthHeaders(),
55
+ };
56
+ for (const healthPath of ['/api/info', '/global/health']) {
57
+ const response = await fetch(`${baseUrl}${healthPath}`, {
58
+ method: 'GET',
59
+ headers,
60
+ signal: controller.signal,
61
+ });
62
+ if (response.ok) {
63
+ const body = await response.json().catch(() => null);
64
+ // Official 2.x /api/info is ServerInfo (version, no healthy);
65
+ // /global/health may still return { healthy, version }.
66
+ if (evaluateOpenCodeHealthBody(body).ok) {
67
+ return true;
68
+ }
64
69
  }
65
70
  }
66
71
  } catch {
@@ -88,8 +93,15 @@ export const createOpenCodeNetworkRuntime = (deps) => {
88
93
  throw new Error('OpenCode port is not available');
89
94
  }
90
95
  const normalizedPath = path.startsWith('/') ? path : `/${path}`;
96
+ // v2 protocol mounts every route under `/api` (`@opencode-ai/protocol`).
97
+ // Callers pass unprefixed API paths (`/session`, `/event`, …); the bare
98
+ // upstream path falls through to the v2 Web UI HTML fallback. `/` stays the
99
+ // upstream origin for SDK base URLs, and already-prefixed paths are kept.
100
+ const apiPath = normalizedPath === '/' || normalizedPath.startsWith('/api')
101
+ ? normalizedPath
102
+ : `/api${normalizedPath}`;
91
103
  const prefix = normalizeApiPrefix(prefixOverride !== undefined ? prefixOverride : '');
92
- const fullPath = `${prefix}${normalizedPath}`;
104
+ const fullPath = `${prefix}${apiPath}`;
93
105
  const base = state.openCodeBaseUrl ?? `http://${resolveConnectHostname()}:${state.openCodePort}`;
94
106
  return `${base}${fullPath}`;
95
107
  };
@@ -23,6 +23,63 @@ describe('OpenCode network runtime', () => {
23
23
  globalThis.fetch = originalFetch;
24
24
  });
25
25
 
26
+ it('probes /api/info with Authorization and treats healthy true as ready', async () => {
27
+ const fetchMock = vi.fn(async (url, options) => {
28
+ expect(String(url)).toContain('/api/info');
29
+ expect(options.headers.Authorization).toMatch(/^Basic /);
30
+ return new Response(JSON.stringify({ version: '2.0.12', pid: 1, urls: [], paths: { tmp: '/tmp' } }), {
31
+ status: 200,
32
+ headers: { 'content-type': 'application/json' },
33
+ });
34
+ });
35
+ globalThis.fetch = fetchMock;
36
+
37
+ const runtime = createOpenCodeNetworkRuntime({
38
+ state: {
39
+ openCodePort: 4096,
40
+ openCodeBaseUrl: null,
41
+ openCodeApiPrefix: '',
42
+ openCodeApiPrefixDetected: false,
43
+ openCodeApiDetectionTimer: null,
44
+ },
45
+ getOpenCodeAuthHeaders: () => ({ Authorization: 'Basic Zml4dHVyZQ==' }),
46
+ });
47
+
48
+ await expect(runtime.waitForReady('http://127.0.0.1:4096', 1000)).resolves.toBe(true);
49
+ expect(String(fetchMock.mock.calls[0][0])).toContain('/api/info');
50
+ });
51
+
52
+ it('falls back to /global/health when /api/info is not healthy', async () => {
53
+ const fetchMock = vi.fn(async (url) => {
54
+ if (String(url).includes('/api/info')) {
55
+ return new Response('not found', { status: 404 });
56
+ }
57
+ if (String(url).includes('/global/health')) {
58
+ return new Response(JSON.stringify({ healthy: true, version: '2.0.12' }), {
59
+ status: 200,
60
+ headers: { 'content-type': 'application/json' },
61
+ });
62
+ }
63
+ return new Response('no', { status: 500 });
64
+ });
65
+ globalThis.fetch = fetchMock;
66
+
67
+ const runtime = createOpenCodeNetworkRuntime({
68
+ state: {
69
+ openCodePort: 4096,
70
+ openCodeBaseUrl: null,
71
+ openCodeApiPrefix: '',
72
+ openCodeApiPrefixDetected: false,
73
+ openCodeApiDetectionTimer: null,
74
+ },
75
+ getOpenCodeAuthHeaders: () => ({ Authorization: 'Basic Zml4dHVyZQ==' }),
76
+ });
77
+
78
+ await expect(runtime.waitForReady('http://127.0.0.1:4096', 1000)).resolves.toBe(true);
79
+ expect(String(fetchMock.mock.calls[0][0])).toContain('/api/info');
80
+ expect(String(fetchMock.mock.calls[1][0])).toContain('/global/health');
81
+ });
82
+
26
83
  it('returns false when readiness fetch rejects', async () => {
27
84
  globalThis.fetch = vi.fn(async () => {
28
85
  throw new Error('offline');
@@ -37,7 +94,7 @@ describe('OpenCode network runtime', () => {
37
94
  it('builds managed OpenCode URLs against IPv4 loopback by default', () => {
38
95
  const runtime = createRuntime();
39
96
 
40
- expect(runtime.buildOpenCodeUrl('/provider')).toBe('http://127.0.0.1:4096/provider');
97
+ expect(runtime.buildOpenCodeUrl('/provider')).toBe('http://127.0.0.1:4096/api/provider');
41
98
  });
42
99
 
43
100
  it('keeps external OpenCode base URLs authoritative', () => {
@@ -45,13 +102,20 @@ describe('OpenCode network runtime', () => {
45
102
  state: { openCodeBaseUrl: 'http://remote.example:4096' },
46
103
  });
47
104
 
48
- expect(runtime.buildOpenCodeUrl('/provider')).toBe('http://remote.example:4096/provider');
105
+ expect(runtime.buildOpenCodeUrl('/provider')).toBe('http://remote.example:4096/api/provider');
106
+ });
107
+
108
+ it('keeps SDK base URLs (/) and already-prefixed paths untouched', () => {
109
+ const runtime = createRuntime();
110
+
111
+ expect(runtime.buildOpenCodeUrl('/')).toBe('http://127.0.0.1:4096/');
112
+ expect(runtime.buildOpenCodeUrl('/api/info')).toBe('http://127.0.0.1:4096/api/info');
49
113
  });
50
114
 
51
115
  it('normalizes wildcard and IPv6 OpenCode bind hosts for local connects', () => {
52
116
  expect(createRuntime({ configuredOpenCodeHostname: '0.0.0.0' }).buildOpenCodeUrl('/provider'))
53
- .toBe('http://127.0.0.1:4096/provider');
117
+ .toBe('http://127.0.0.1:4096/api/provider');
54
118
  expect(createRuntime({ configuredOpenCodeHostname: '::1' }).buildOpenCodeUrl('/provider'))
55
- .toBe('http://[::1]:4096/provider');
119
+ .toBe('http://[::1]:4096/api/provider');
56
120
  });
57
121
  });