@openchambery/web 2.0.0-beta.26 → 2.0.0-beta.28

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 (175) hide show
  1. package/dist/assets/{AgentAvatar-FyTdEOMr.js → AgentAvatar-CqD6Pfom.js} +1 -1
  2. package/dist/assets/{AssistantView-DvOO2Kf5.js → AssistantView-BW9f9vF2.js} +3 -3
  3. package/dist/assets/{DiagramView-BqjeSOIg.js → DiagramView-BP3WDmFe.js} +1 -1
  4. package/dist/assets/{FadeInOnReveal-DHDffzHs.js → FadeInOnReveal-ezW_e-yz.js} +1 -1
  5. package/dist/assets/{Icon-D7NdMmbi.js → Icon-ChOAx1jX.js} +1 -1
  6. package/dist/assets/{MarkdownRenderer-kpkRqS42.js → MarkdownRenderer-BBHZa1Ts.js} +2 -2
  7. package/dist/assets/{MarkdownRendererImpl-CzBos-Gt.js → MarkdownRendererImpl-DfxEsttZ.js} +2 -2
  8. package/dist/assets/{MarkstreamRendererImpl-RY_rAgKD.js → MarkstreamRendererImpl-6k-n071l.js} +1 -1
  9. package/dist/assets/{MobileDetailNavigation-DgtRh0zu.js → MobileDetailNavigation-s48GkQY9.js} +1 -1
  10. package/dist/assets/{MobileShareBridge-BM3cN-gG.js → MobileShareBridge-DAgShkFE.js} +2 -2
  11. package/dist/assets/MobileSurface-DZug2f4O.js +2 -0
  12. package/dist/assets/{MultiRunWindow-Bq4gz78C.js → MultiRunWindow-CIhHw9mo.js} +1 -1
  13. package/dist/assets/{OnboardingScreen-DMbPMwlp.js → OnboardingScreen-ozxwbzDw.js} +1 -1
  14. package/dist/assets/{OverlayScrollbar-mI9WVa7E.js → OverlayScrollbar-BL-QnnrN.js} +1 -1
  15. package/dist/assets/SettingsView-DmUdWSji.js +25 -0
  16. package/dist/assets/{SettingsWindow-CHPjbEnI.js → SettingsWindow-CuIZwGhM.js} +1 -1
  17. package/dist/assets/{TerminalView-DCJuL2nP.js → TerminalView-BIJ-ILgl.js} +1 -1
  18. package/dist/assets/{ToolOutputDialog-Cg_Q1t3X.js → ToolOutputDialog-B6KIxVUo.js} +1 -1
  19. package/dist/assets/{appThemeRegistry-BxuflaQt.js → appThemeRegistry-BuMGUGUK.js} +1 -1
  20. package/dist/assets/{button-e8fYWIMM.js → button-DoDvdeG-.js} +1 -1
  21. package/dist/assets/{collapsible-B6wLXHi3.js → collapsible-CZkyFVK0.js} +1 -1
  22. package/dist/assets/{context-menu-Bq7W1d7y.js → context-menu-DTjON-Ta.js} +1 -1
  23. package/dist/assets/{desktopHosts-C8vf9vRY.js → desktopHosts-CP1YHQ4Z.js} +1 -1
  24. package/dist/assets/{desktopRelayRestore-DDJtljeu.js → desktopRelayRestore-BeOGO7oA.js} +1 -1
  25. package/dist/assets/{diagram-BBL-LfsC.js → diagram-DUi801df.js} +1 -1
  26. package/dist/assets/{dialog-MRsJpHzu.js → dialog-DGzGqOhG.js} +1 -1
  27. package/dist/assets/{es-DvPwAj0y.js → es-DCjl24dV.js} +3 -3
  28. package/dist/assets/fileMentionTouchSelection-C3A6XKRM.js +2 -0
  29. package/dist/assets/{fileQueries-CBu-ytXS.js → fileQueries-CAH8-p27.js} +1 -1
  30. package/dist/assets/{fr-XQsoaNKY.js → fr-DfJ5j0t5.js} +3 -3
  31. package/dist/assets/{gitApi-CuIUGt04.js → gitApi-B1Nyw0PD.js} +3 -3
  32. package/dist/assets/{input-DbOe1mdT.js → input-Ct2uaiDc.js} +1 -1
  33. package/dist/assets/insertionBoundaries-BypTPUfp.js +1 -0
  34. package/dist/assets/{ja-DslzO96s.js → ja-B_9_w2Xf.js} +3 -3
  35. package/dist/assets/{ko-C_9XOAL0.js → ko-DlGbo3kV.js} +3 -3
  36. package/dist/assets/main-BREqmQUm.js +169 -0
  37. package/dist/assets/{main-Sd4Auh_x.js → main-BodZWzei.js} +2 -2
  38. package/dist/assets/main-qSeeJRW1.css +1 -0
  39. package/dist/assets/{markdownHeightCache-B5VSLl79.js → markdownHeightCache-CMoWScss.js} +1 -1
  40. package/dist/assets/{mcpQueries-BYNrkehM.js → mcpQueries-nScpWEqu.js} +1 -1
  41. package/dist/assets/{miniChat-Hp9bNmS0.js → miniChat-Ca-kdWf0.js} +2 -2
  42. package/dist/assets/{mobile-fBLEOx9_.js → mobile-BWi6F8Lj.js} +2 -2
  43. package/dist/assets/multirun-BdSrR6jN.js +3 -0
  44. package/dist/assets/persistence-B3_CPgm1.js +3 -0
  45. package/dist/assets/{pl-Dst9Wk53.js → pl-DjUsS8Ei.js} +3 -3
  46. package/dist/assets/platform-Bxdc4SbV.js +6 -0
  47. package/dist/assets/projectMeta-CIKLMweH.js +1 -0
  48. package/dist/assets/{pt-BR-Dq394BPb.js → pt-BR-B8sV7qo1.js} +3 -3
  49. package/dist/assets/{renderElectronMiniChatApp-D1xipCO5.js → renderElectronMiniChatApp-DtYCIW4n.js} +2 -2
  50. package/dist/assets/renderMobileApp-D47vojsP.js +28 -0
  51. package/dist/assets/{runtimeConfig-pTYamMLy.js → runtimeConfig-u9DrZmkS.js} +1 -1
  52. package/dist/assets/runtimeEndpointReset-Bn4vJ-gl.js +148 -0
  53. package/dist/assets/runtimeEndpointReset-D1_Y5I4F.css +1 -0
  54. package/dist/assets/{sessionChangeSummary-DcnZyuWt.js → sessionChangeSummary-C0OxmHed.js} +2 -2
  55. package/dist/assets/{sessionLookup-B3t-FerN.js → sessionLookup-D1NHRgWf.js} +1 -1
  56. package/dist/assets/{sortable-tabs-strip-B0sZ7mm6.js → sortable-tabs-strip-Cp5i5hI-.js} +1 -1
  57. package/dist/assets/{store-D8fmTWK3.js → store-BTqPn8Uq.js} +3 -3
  58. package/dist/assets/{terminalApi-CJyWCuc8.js → terminalApi-CqyW3_W3.js} +1 -1
  59. package/dist/assets/{textarea-B5GMntHk.js → textarea-jEbc6gqf.js} +1 -1
  60. package/dist/assets/{tooltip-C64SdRvR.js → tooltip-DKYT7xYe.js} +1 -1
  61. package/dist/assets/{uk-8XAR75Qh.js → uk-Bh7HXyLp.js} +3 -3
  62. package/dist/assets/{url-DF1NFwt_.js → url-hhmiocZY.js} +1 -1
  63. package/dist/assets/{useAppFontEffects-BQklk6XB.js → useAppFontEffects-BBX1KjLV.js} +53 -53
  64. package/dist/assets/{useDesktopWindowControlsLayout-CrzYwZDQ.js → useDesktopWindowControlsLayout-uKYzUX-m.js} +1 -1
  65. package/dist/assets/{useEffectiveDirectory-Ddx7sZ5A.js → useEffectiveDirectory-BOolveMp.js} +1 -1
  66. package/dist/assets/{useFontPreferences-CrteQiAp.js → useFontPreferences-BMJQP6t6.js} +1 -1
  67. package/dist/assets/useMobileNavigationStore-CC8QT7VC.js +415 -0
  68. package/dist/assets/useSessionAutoCleanup-CydMAlLf.js +2 -0
  69. package/dist/assets/{useWorkerHighlightedLines-VOtVUGkO.js → useWorkerHighlightedLines-BF1ClmMe.js} +1 -1
  70. package/dist/assets/vendor-opencode-client-MF2GBrFx.js +12 -0
  71. package/dist/assets/{zh-CN-B-ILfrqC.js → zh-CN-Uzia0gk4.js} +3 -3
  72. package/dist/assets/{zh-TW-5HeTVP4z.js → zh-TW-CiI7DLdB.js} +3 -3
  73. package/dist/index.html +8 -8
  74. package/dist/mini-chat.html +8 -8
  75. package/dist/mobile.html +8 -8
  76. package/package.json +3 -3
  77. package/server/index.js +2 -20
  78. package/server/lib/git/DOCUMENTATION.md +1 -0
  79. package/server/lib/git/routes.js +7 -4
  80. package/server/lib/git/routes.test.js +34 -0
  81. package/server/lib/git/service.js +3 -1
  82. package/server/lib/llm/DOCUMENTATION.md +6 -4
  83. package/server/lib/llm/generate.js +8 -9
  84. package/server/lib/llm/generate.test.js +33 -6
  85. package/server/lib/opencode/DOCUMENTATION.md +24 -20
  86. package/server/lib/opencode/agents.js +13 -22
  87. package/server/lib/opencode/auth-state-runtime.js +3 -3
  88. package/server/lib/opencode/auth-state-runtime.test.js +59 -0
  89. package/server/lib/opencode/auth.d.ts +13 -0
  90. package/server/lib/opencode/auth.js +74 -64
  91. package/server/lib/opencode/auth.test.js +57 -0
  92. package/server/lib/opencode/commands.js +1 -1
  93. package/server/lib/opencode/config-entities-layering.test.js +72 -0
  94. package/server/lib/opencode/config-entity-routes.js +9 -122
  95. package/server/lib/opencode/config-entity-routes.test.js +7 -11
  96. package/server/lib/opencode/config-files.d.ts +17 -0
  97. package/server/lib/opencode/config-files.js +187 -0
  98. package/server/lib/opencode/config-files.test.js +201 -0
  99. package/server/lib/opencode/core-routes.js +0 -1
  100. package/server/lib/opencode/core-routes.test.js +0 -15
  101. package/server/lib/opencode/credential-boundary.d.ts +1 -0
  102. package/server/lib/opencode/credential-boundary.js +11 -0
  103. package/server/lib/opencode/ensure-cli.js +4 -1
  104. package/server/lib/opencode/env-config.js +4 -1
  105. package/server/lib/opencode/env-config.test.js +28 -0
  106. package/server/lib/opencode/env-runtime.test.js +2 -1
  107. package/server/lib/opencode/feature-routes-runtime.js +9 -7
  108. package/server/lib/opencode/hmr-state-runtime.js +4 -4
  109. package/server/lib/opencode/lifecycle.js +1 -49
  110. package/server/lib/opencode/lifecycle.test.js +30 -4
  111. package/server/lib/opencode/location-release.d.ts +8 -0
  112. package/server/lib/opencode/location-release.js +32 -0
  113. package/server/lib/opencode/location-release.test.js +43 -0
  114. package/server/lib/opencode/mcp.js +1 -1
  115. package/server/lib/opencode/opencode2-pin.js +2 -1
  116. package/server/lib/opencode/owned-runtime-upgrade.test.js +5 -5
  117. package/server/lib/opencode/plugins.js +12 -60
  118. package/server/lib/opencode/providers.js +6 -3
  119. package/server/lib/opencode/proxy.js +7 -0
  120. package/server/lib/opencode/proxy.test.js +13 -0
  121. package/server/lib/opencode/routes.js +5 -5
  122. package/server/lib/opencode/routes.test.js +18 -18
  123. package/server/lib/opencode/runtime-contract.js +3 -2
  124. package/server/lib/opencode/runtime-contract.test.js +12 -12
  125. package/server/lib/opencode/shared.js +6 -175
  126. package/server/lib/opencode/upgrade-screen.js +4 -11
  127. package/server/lib/opencode/upgrade-screen.test.js +25 -25
  128. package/server/lib/opencode/version-alignment.test.js +40 -0
  129. package/server/lib/quota/DOCUMENTATION.md +9 -0
  130. package/server/lib/quota/provider-credentials.test.js +29 -0
  131. package/server/lib/quota/providers/claude.js +4 -4
  132. package/server/lib/quota/providers/codex.js +4 -4
  133. package/server/lib/quota/providers/codex.test.js +1 -1
  134. package/server/lib/quota/providers/copilot.js +5 -5
  135. package/server/lib/quota/providers/google/auth.js +3 -3
  136. package/server/lib/quota/providers/google/index.js +2 -2
  137. package/server/lib/quota/providers/index.js +3 -7
  138. package/server/lib/quota/providers/index.test.js +2 -2
  139. package/server/lib/quota/providers/kimi.js +4 -4
  140. package/server/lib/quota/providers/minimax-shared.js +4 -4
  141. package/server/lib/quota/providers/nanogpt.js +4 -4
  142. package/server/lib/quota/providers/openai.js +4 -4
  143. package/server/lib/quota/providers/openrouter.js +4 -4
  144. package/server/lib/quota/providers/wafer.js +4 -4
  145. package/server/lib/quota/providers/zai.js +4 -4
  146. package/server/lib/quota/providers/zhipuai-coding-plan.js +6 -20
  147. package/server/lib/quota/routes.js +1 -1
  148. package/server/lib/scheduled-tasks/DOCUMENTATION.md +1 -0
  149. package/server/lib/scheduled-tasks/runtime.js +2 -3
  150. package/server/lib/session-index/DOCUMENTATION.md +7 -0
  151. package/server/lib/session-index/event-ingest.js +13 -0
  152. package/server/lib/session-index/event-ingest.test.js +7 -0
  153. package/server/lib/session-index/service.js +45 -0
  154. package/server/lib/session-index/service.test.js +47 -0
  155. package/server/lib/tts/DOCUMENTATION.md +7 -5
  156. package/server/lib/tts/credential-source.test.js +23 -0
  157. package/server/lib/tts/routes.js +2 -2
  158. package/server/lib/tts/service.js +16 -37
  159. package/dist/assets/MobileSurface-ocVWenHA.js +0 -2
  160. package/dist/assets/SettingsView-CIZz40Jg.js +0 -25
  161. package/dist/assets/fileMentionTouchSelection-Bj67pPo7.js +0 -3
  162. package/dist/assets/insertionBoundaries-nzs1Q3YR.js +0 -1
  163. package/dist/assets/main-D2BzpnUt.css +0 -1
  164. package/dist/assets/main-DjilfYN7.js +0 -169
  165. package/dist/assets/multirun-D7W7oCj4.js +0 -4
  166. package/dist/assets/persistence-CMWO3jFo.js +0 -3
  167. package/dist/assets/platform-B0p43squ.js +0 -6
  168. package/dist/assets/projectMeta-DrUKrYt0.js +0 -3
  169. package/dist/assets/renderMobileApp-DSk7Qs-_.js +0 -28
  170. package/dist/assets/runtimeEndpointReset-B5HEMuiB.js +0 -149
  171. package/dist/assets/useMobileNavigationStore-i4cXNoOf.js +0 -414
  172. package/dist/assets/useSessionAutoCleanup-Dzd0fGSe.js +0 -2
  173. package/dist/assets/vendor-opencode-client-C12gfjag.js +0 -12
  174. package/server/lib/opencode/snippets.js +0 -242
  175. package/server/lib/opencode/snippets.test.js +0 -97
@@ -29,8 +29,6 @@ const OPENCODE_CHILD_SIGTERM_GRACE_MS = 2500;
29
29
  const OPENCODE_CHILD_SIGKILL_WAIT_MS = 1000;
30
30
  // Last-used directory plus recently opened projects — deeper tails are unlikely
31
31
  // to be the user's first click and just add background work.
32
- const WARMUP_DIRECTORY_LIMIT = 4;
33
- const WARMUP_REQUEST_TIMEOUT_MS = 30000;
34
32
 
35
33
  export const createOpenCodeLifecycleRuntime = (deps) => {
36
34
  const {
@@ -57,7 +55,6 @@ export const createOpenCodeLifecycleRuntime = (deps) => {
57
55
  getManagedOpenCodeShellEnvSnapshot,
58
56
  managedCapabilitiesRuntime = null,
59
57
  getActiveSessionCount = () => 0,
60
- getWarmupDirectories = async () => [],
61
58
  // Official background service (shared-service.js). null keeps the private
62
59
  // managed `opencode serve` path.
63
60
  sharedService = null,
@@ -796,6 +793,7 @@ export const createOpenCodeLifecycleRuntime = (deps) => {
796
793
  ...shellEnv,
797
794
  ...process.env,
798
795
  PATH: envPath,
796
+ OPENCODE_PASSWORD: openCodePassword,
799
797
  OPENCODE_SERVER_PASSWORD: openCodePassword,
800
798
  };
801
799
  const managedEnv = managedCapabilitiesRuntime
@@ -1253,11 +1251,6 @@ export const createOpenCodeLifecycleRuntime = (deps) => {
1253
1251
  await waitForOpenCodePort();
1254
1252
  try {
1255
1253
  await waitForOpenCodeReady();
1256
- // Warm only after readiness admits; a failed gate must not kick off
1257
- // directory-scoped traffic against an unready upstream.
1258
- if (state.isOpenCodeReady) {
1259
- void warmOpenCodeDirectories();
1260
- }
1261
1254
  } catch (error) {
1262
1255
  console.error(`OpenCode readiness check failed: ${error.message}`);
1263
1256
  }
@@ -1268,47 +1261,6 @@ export const createOpenCodeLifecycleRuntime = (deps) => {
1268
1261
  }
1269
1262
  };
1270
1263
 
1271
- // OpenCode initializes each project directory lazily on its first
1272
- // directory-scoped request, and that initialization takes seconds on large
1273
- // session stores. Without warming, the user's first session open pays it
1274
- // interactively. Warm the most recently used directories right after
1275
- // readiness so the work overlaps UI startup instead. Sequential and
1276
- // best-effort: a failed or slow directory never blocks the others for long,
1277
- // and a restart invalidates the pass via the port/readiness guard.
1278
- const warmOpenCodeDirectories = async () => {
1279
- let directories = [];
1280
- try {
1281
- directories = await getWarmupDirectories();
1282
- } catch {
1283
- return;
1284
- }
1285
- if (!Array.isArray(directories) || directories.length === 0) return;
1286
-
1287
- const warmedPort = state.openCodePort;
1288
- for (const directory of directories.slice(0, WARMUP_DIRECTORY_LIMIT)) {
1289
- if (typeof directory !== 'string' || !directory) continue;
1290
- if (!state.isOpenCodeReady || state.openCodePort !== warmedPort) return;
1291
- let timeout = null;
1292
- try {
1293
- const controller = new AbortController();
1294
- timeout = setTimeout(() => controller.abort(), WARMUP_REQUEST_TIMEOUT_MS);
1295
- // Warming a directory is the point, not the answer: any directory-scoped
1296
- // read makes OpenCode initialise it. `/api/session` is the cheapest one
1297
- // that takes a directory.
1298
- const url = `${buildOpenCodeUrl('/api/session', '')}?directory=${encodeURIComponent(directory)}&limit=1`;
1299
- await fetch(url, {
1300
- method: 'GET',
1301
- headers: { Accept: 'application/json', ...getOpenCodeAuthHeaders() },
1302
- signal: controller.signal,
1303
- });
1304
- } catch {
1305
- // Best-effort — the directory stays lazy and the UI's own request warms it.
1306
- } finally {
1307
- if (timeout) clearTimeout(timeout);
1308
- }
1309
- }
1310
- };
1311
-
1312
1264
  const bootstrapOpenCodeAtStartup = () => {
1313
1265
  if (!bootstrapOpenCodePromise) {
1314
1266
  bootstrapOpenCodePromise = runBootstrapOpenCodeAtStartup().finally(() => {
@@ -10,6 +10,8 @@ vi.mock('node:child_process', () => ({
10
10
 
11
11
  const { createOpenCodeLifecycleRuntime } = await import('./lifecycle.js');
12
12
  const { createOpenCodeNetworkRuntime } = await import('./network-runtime.js');
13
+ const { resolveOpenCodeEnvConfig } = await import('./env-config.js');
14
+ const { RUNTIME_CONTRACT_MIN_VERIFIED } = await import('./runtime-contract.js');
13
15
 
14
16
  const originalOpencodeBinary = process.env.OPENCODE_BINARY;
15
17
  const originalPath = process.env.PATH;
@@ -106,6 +108,7 @@ const createRuntime = (overrides = {}, stateRef = null) => {
106
108
  getManagedOpenCodeShellEnvSnapshot: vi.fn(() => ({
107
109
  PATH: '/home/user/.bun/bin:/usr/local/bin:/usr/bin',
108
110
  SHELL_ONLY: 'yes',
111
+ OPENCODE_PASSWORD: 'shell-v2-password',
109
112
  OPENCODE_SERVER_PASSWORD: 'shell-password',
110
113
  })),
111
114
  ...overrides,
@@ -160,6 +163,28 @@ const startListeningChild = () => {
160
163
  };
161
164
 
162
165
  describe('OpenCode lifecycle', () => {
166
+ it.each(['http://example.test:80', 'https://example.test:443', 'http://[::1]:80'])('attaches explicit default-port host %s without spawning', async (host) => {
167
+ const config = resolveOpenCodeEnvConfig({ env: { OPENCODE_HOST: host, OPENCODE_PORT: '9999' } });
168
+ const stateRef = {};
169
+ const runtime = createRuntime({
170
+ env: {
171
+ ENV_CONFIGURED_OPENCODE_PORT: config.configuredOpenCodePort,
172
+ ENV_CONFIGURED_OPENCODE_HOST: config.configuredOpenCodeHost,
173
+ ENV_EFFECTIVE_PORT: config.effectivePort,
174
+ ENV_CONFIGURED_OPENCODE_HOSTNAME: '127.0.0.1',
175
+ ENV_SKIP_OPENCODE_START: true,
176
+ },
177
+ buildOpenCodeUrl: (route) => new URL(route, config.configuredOpenCodeHost.origin).href,
178
+ }, stateRef);
179
+ const fetchMock = stubOpenCodeFetch({ health: { ...DEFAULT_V2_HEALTH, version: '2.0.23' } });
180
+ await runtime.bootstrapOpenCodeAtStartup();
181
+ await runtime.waitForOpenCodeReady(1000, 1);
182
+ expect(stateRef.current.isExternalOpenCode).toBe(true);
183
+ expect(stateRef.current.openCodePort).toBe(config.effectivePort);
184
+ expect(fetchMock.mock.calls.some(([url]) => String(url) === `${config.configuredOpenCodeHost.origin}/api/info`)).toBe(true);
185
+ expect(spawnMock).not.toHaveBeenCalled();
186
+ });
187
+
163
188
  it('launches managed OpenCode with the managed PATH', async () => {
164
189
  delete process.env.OPENCODE_BINARY;
165
190
  const child = createMockChild();
@@ -179,6 +204,7 @@ describe('OpenCode lifecycle', () => {
179
204
  expect(options.env.PATH).toBe('/home/user/.bun/bin:/usr/local/bin:/usr/bin');
180
205
  expect(options.env.SHELL_ONLY).toBe('yes');
181
206
  expect(options.env.OPENCODE_SERVER_PASSWORD).toBe('password');
207
+ expect(options.env.OPENCODE_PASSWORD).toBe('password');
182
208
 
183
209
  await server.close();
184
210
  });
@@ -847,7 +873,7 @@ describe('OpenCode lifecycle', () => {
847
873
  stateRef.current.runtimeContract = {
848
874
  executionAllowed: true,
849
875
  phase: 'ready',
850
- serveVersion: '2.0.12',
876
+ serveVersion: RUNTIME_CONTRACT_MIN_VERIFIED,
851
877
  instanceGeneration: 1,
852
878
  };
853
879
 
@@ -861,7 +887,7 @@ describe('OpenCode lifecycle', () => {
861
887
  runtime.refreshRuntimeContractFromProbe(
862
888
  {
863
889
  ok: true,
864
- version: '2.0.12',
890
+ version: RUNTIME_CONTRACT_MIN_VERIFIED,
865
891
  authenticated: true,
866
892
  healthOk: true,
867
893
  },
@@ -875,7 +901,7 @@ describe('OpenCode lifecycle', () => {
875
901
  runtime.refreshRuntimeContractFromProbe(
876
902
  {
877
903
  ok: true,
878
- version: '2.0.12',
904
+ version: RUNTIME_CONTRACT_MIN_VERIFIED,
879
905
  authenticated: true,
880
906
  healthOk: true,
881
907
  },
@@ -884,7 +910,7 @@ describe('OpenCode lifecycle', () => {
884
910
  );
885
911
  expect(stateRef.current.runtimeContract.executionAllowed).toBe(true);
886
912
  expect(stateRef.current.runtimeContract.instanceGeneration).toBe(2);
887
- expect(stateRef.current.runtimeContract.serveVersion).toBe('2.0.12');
913
+ expect(stateRef.current.runtimeContract.serveVersion).toBe(RUNTIME_CONTRACT_MIN_VERIFIED);
888
914
  });
889
915
 
890
916
  it('does not mark external skip-start ready before version and migration admit', async () => {
@@ -0,0 +1,8 @@
1
+ import type { OpenCodeClient } from '@opencode/client';
2
+
3
+ export type LocationReleaseResult = { state: 'released' | 'timeout' | 'failed' | 'unavailable' };
4
+ export function releaseDeletedWorktreeLocation(input: {
5
+ getClient: () => OpenCodeClient | null;
6
+ directory: string;
7
+ timeoutMs?: number;
8
+ }): Promise<LocationReleaseResult>;
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Explicit worktree deletion only. An eviction acknowledgement detaches cached
3
+ * services; it does not prove that borrowers have released every OS handle.
4
+ * Never use this operation for automatic navigation cleanup on a shared serve.
5
+ *
6
+ * @param {{ getClient: () => import('@opencode/client').OpenCodeClient | null, directory: string, timeoutMs?: number }} input
7
+ * @returns {Promise<{ state: 'released' | 'timeout' | 'failed' | 'unavailable' }>}
8
+ */
9
+ export async function releaseDeletedWorktreeLocation({ getClient, directory, timeoutMs = 2000 }) {
10
+ const controller = new AbortController();
11
+ let timer;
12
+ try {
13
+ if (!directory || !directory.trim()) return { state: 'unavailable' };
14
+ const client = getClient();
15
+ if (!client) return { state: 'unavailable' };
16
+ const deadline = new Promise((resolve) => {
17
+ timer = setTimeout(() => {
18
+ controller.abort();
19
+ resolve({ state: 'timeout' });
20
+ }, timeoutMs);
21
+ });
22
+ const request = Promise.resolve().then(() => client.debug.location.evict(
23
+ { location: { directory } },
24
+ { signal: controller.signal },
25
+ )).then(() => ({ state: 'released' }), () => ({ state: controller.signal.aborted ? 'timeout' : 'failed' }));
26
+ return await Promise.race([request, deadline]);
27
+ } catch {
28
+ return { state: 'failed' };
29
+ } finally {
30
+ clearTimeout(timer);
31
+ }
32
+ }
@@ -0,0 +1,43 @@
1
+ import { afterEach, describe, expect, it, vi } from 'vitest';
2
+ import { OpenCode } from '@opencode/client';
3
+ import { releaseDeletedWorktreeLocation } from './location-release.js';
4
+
5
+ afterEach(() => vi.useRealTimers());
6
+
7
+ describe('explicit worktree location release', () => {
8
+ it('uses the official SDK exact target, current origin and auth', async () => {
9
+ const requests = [];
10
+ let origin = 'https://old.invalid';
11
+ const getClient = () => OpenCode.make({ baseUrl: origin, headers: { Authorization: 'test-auth' }, fetch: async (input, init) => {
12
+ requests.push(new Request(input, init));
13
+ return new Response(null, { status: 204 });
14
+ } });
15
+ origin = 'https://current.invalid';
16
+ expect(await releaseDeletedWorktreeLocation({ getClient, directory: '/repo/work tree' })).toEqual({ state: 'released' });
17
+ expect(requests).toHaveLength(1);
18
+ const url = new URL(requests[0].url);
19
+ expect(url.origin + url.pathname).toBe('https://current.invalid/api/debug/location');
20
+ expect(url.searchParams.get('location[directory]')).toBe('/repo/work tree');
21
+ expect(requests[0].method).toBe('DELETE');
22
+ expect(requests[0].headers.get('Authorization')).toBe('test-auth');
23
+ });
24
+
25
+ it('reports failure without claiming successful disposal', async () => {
26
+ const getClient = () => ({ debug: { location: { evict: async () => { throw new Error('unavailable'); } } } });
27
+ expect(await releaseDeletedWorktreeLocation({ getClient, directory: '/repo' })).toEqual({ state: 'failed' });
28
+ expect(await releaseDeletedWorktreeLocation({ getClient: () => null, directory: '/repo' })).toEqual({ state: 'unavailable' });
29
+ });
30
+
31
+ it('bounds even a transport that ignores abort and never settles', async () => {
32
+ vi.useFakeTimers();
33
+ let signal;
34
+ const getClient = () => ({ debug: { location: { evict: (_input, options) => {
35
+ signal = options.signal;
36
+ return new Promise(() => {});
37
+ } } } });
38
+ const result = releaseDeletedWorktreeLocation({ getClient, directory: '/repo', timeoutMs: 50 });
39
+ await vi.advanceTimersByTimeAsync(50);
40
+ expect(await result).toEqual({ state: 'timeout' });
41
+ expect(signal.aborted).toBe(true);
42
+ });
43
+ });
@@ -29,7 +29,7 @@ function validateMcpName(name) {
29
29
  */
30
30
  function resolveMcpScopeFromPath(layers, sourcePath) {
31
31
  if (!sourcePath) return null;
32
- return sourcePath === layers.paths.projectPath ? AGENT_SCOPE.PROJECT : AGENT_SCOPE.USER;
32
+ return layers.projectLayers.some((layer) => layer.path === sourcePath) ? AGENT_SCOPE.PROJECT : AGENT_SCOPE.USER;
33
33
  }
34
34
 
35
35
  function ensureProjectMcpConfigPath(workingDirectory) {
@@ -1,5 +1,6 @@
1
1
  // Pinned desktop/runtime opencode2. Upgrade and prepare must not fall back to 1.18.x.
2
- export const PINNED_OPENCODE2_VERSION = '2.0.12';
2
+ export const PINNED_OPENCODE2_VERSION = '2.0.23';
3
+ export const MINIMUM_OPENCODE2_VERSION = '2.0.20';
3
4
 
4
5
  // Global npm/bun package that installs the opencode v2 binary (not 1.x opencode-ai).
5
6
  export const OPENCODE2_NPM_PACKAGE = '@opencode/cli';
@@ -86,15 +86,15 @@ describe('owned runtime upgrade (ticket 12)', () => {
86
86
 
87
87
  it('verifies running serve identity before upgrade success', () => {
88
88
  const contractOk = evaluateRuntimeContract({
89
- serveVersion: '2.0.14',
89
+ serveVersion: '2.0.23',
90
90
  reachable: true,
91
91
  authenticated: true,
92
92
  healthOk: true,
93
93
  migrationAdmitTranscript: true,
94
94
  });
95
95
  expect(evaluateOwnedUpgradeResult({
96
- targetVersion: '2.0.14',
97
- serveVersion: '2.0.14',
96
+ targetVersion: '2.0.23',
97
+ serveVersion: '2.0.23',
98
98
  binaryPath: ownedBinary,
99
99
  expectedBinaryPath: ownedBinary,
100
100
  contract: contractOk,
@@ -116,8 +116,8 @@ describe('owned runtime upgrade (ticket 12)', () => {
116
116
  });
117
117
 
118
118
  expect(evaluateOwnedUpgradeResult({
119
- targetVersion: '2.0.14',
120
- serveVersion: '2.0.14',
119
+ targetVersion: '2.0.23',
120
+ serveVersion: '2.0.23',
121
121
  binaryPath: '/usr/bin/opencode',
122
122
  expectedBinaryPath: ownedBinary,
123
123
  contract: contractOk,
@@ -4,6 +4,8 @@ import path from 'path';
4
4
  import {
5
5
  AGENT_SCOPE,
6
6
  readConfigFile,
7
+ readConfigLayers,
8
+ getConfigForPath,
7
9
  writeConfig,
8
10
  } from './shared.js';
9
11
  import { isPathSpec } from './plugin-spec.js';
@@ -74,54 +76,8 @@ function getActiveOpencodeConfigDir() {
74
76
  return path.join(os.homedir(), '.config', 'opencode');
75
77
  }
76
78
 
77
- function getActiveUserConfigPaths() {
78
- const configDir = getActiveOpencodeConfigDir();
79
- return [
80
- path.join(configDir, 'config.json'),
81
- path.join(configDir, 'opencode.json'),
82
- path.join(configDir, 'opencode.jsonc'),
83
- ];
84
- }
85
-
86
- function getActiveCustomConfigPath() {
87
- return process.env.OPENCODE_CONFIG ? path.resolve(process.env.OPENCODE_CONFIG) : null;
88
- }
89
-
90
- function getPrimaryUserConfigPath() {
91
- const [defaultPath, ...fallbackPaths] = getActiveUserConfigPaths();
92
- for (const userPath of [defaultPath, ...fallbackPaths]) {
93
- if (fs.existsSync(userPath)) {
94
- return userPath;
95
- }
96
- }
97
- return defaultPath;
98
- }
99
-
100
- function getProjectConfigPath(workingDirectory) {
101
- if (!workingDirectory) return null;
102
- const candidates = [
103
- path.join(workingDirectory, 'opencode.json'),
104
- path.join(workingDirectory, 'opencode.jsonc'),
105
- path.join(workingDirectory, '.opencode', 'opencode.json'),
106
- path.join(workingDirectory, '.opencode', 'opencode.jsonc'),
107
- ];
108
- return candidates.find((candidate) => fs.existsSync(candidate)) || candidates[0];
109
- }
110
-
111
79
  function readPluginConfigLayers(workingDirectory) {
112
- const customPath = getActiveCustomConfigPath();
113
- const userPath = getPrimaryUserConfigPath();
114
- const projectPath = getProjectConfigPath(workingDirectory);
115
- return {
116
- userConfig: readConfigFile(userPath),
117
- projectConfig: readConfigFile(projectPath),
118
- customConfig: readConfigFile(customPath),
119
- paths: {
120
- userPath,
121
- projectPath,
122
- customPath,
123
- },
124
- };
80
+ return readConfigLayers(workingDirectory);
125
81
  }
126
82
 
127
83
  function validateFileName(fileName) {
@@ -144,16 +100,10 @@ function ensureProjectConfigPath(workingDirectory) {
144
100
  }
145
101
 
146
102
  function configSources(layers) {
147
- const sources = [];
148
- if (layers.paths.customPath) {
149
- sources.push({ config: layers.customConfig, filePath: layers.paths.customPath, scope: AGENT_SCOPE.USER });
150
- } else {
151
- sources.push({ config: layers.userConfig, filePath: layers.paths.userPath, scope: AGENT_SCOPE.USER });
152
- }
153
- if (layers.paths.projectPath) {
154
- sources.push({ config: layers.projectConfig, filePath: layers.paths.projectPath, scope: AGENT_SCOPE.PROJECT });
155
- }
156
- return sources;
103
+ return [...layers.documents.filter((layer) => layer.scope !== 'project').reverse(), ...layers.projectLayers.toReversed()].map((layer) => ({
104
+ config: layer.config, filePath: layer.path,
105
+ scope: layer.scope === 'project' ? AGENT_SCOPE.PROJECT : AGENT_SCOPE.USER,
106
+ }));
157
107
  }
158
108
 
159
109
  function splitScopedValue(value) {
@@ -175,7 +125,9 @@ function getPluginTarget(id, workingDirectory) {
175
125
  const { scope, value: spec } = splitScopedValue(decoded.value);
176
126
  validateScope(scope);
177
127
  const layers = readPluginConfigLayers(workingDirectory);
178
- const source = configSources(layers).find((candidate) => candidate.scope === scope);
128
+ const source = configSources(layers).find((candidate) => candidate.scope === scope
129
+ && Array.isArray(candidate.config?.plugin)
130
+ && candidate.config.plugin.some((raw) => parsePluginRaw(raw).spec === spec));
179
131
  const plugin = Array.isArray(source?.config?.plugin) ? source.config.plugin : [];
180
132
  const index = plugin.findIndex((raw) => parsePluginRaw(raw).spec === spec);
181
133
  if (!source || index === -1) {
@@ -281,14 +233,14 @@ function createPluginEntry(entry, workingDirectory) {
281
233
  throw codedError(`Plugin "${spec}" already exists`, 'ENTRY_EXISTS');
282
234
  }
283
235
 
284
- let targetPath = getPrimaryUserConfigPath();
236
+ let targetPath = layers.paths.userPath;
285
237
  let config = {};
286
238
  if (scope === AGENT_SCOPE.PROJECT) {
287
239
  targetPath = ensureProjectConfigPath(workingDirectory);
288
240
  config = fs.existsSync(targetPath) ? readConfigFile(targetPath) : {};
289
241
  } else {
290
242
  targetPath = layers.paths.customPath || layers.paths.userPath;
291
- config = layers.paths.customPath ? layers.customConfig : layers.userConfig;
243
+ config = getConfigForPath(layers, targetPath);
292
244
  }
293
245
 
294
246
  if (!Array.isArray(config.plugin)) {
@@ -30,8 +30,8 @@ function getProviderSources(providerId, workingDirectory) {
30
30
  return {
31
31
  sources: {
32
32
  auth: { exists: false },
33
- user: { exists: userExists, path: paths.userPath },
34
- project: { exists: projectExists, path: paths.projectPath || null },
33
+ user: { exists: userExists, path: layers.userLayers.findLast((layer) => layer.config?.provider?.[providerId] !== undefined || layer.config?.providers?.[providerId] !== undefined)?.path || paths.userPath },
34
+ project: { exists: projectExists, path: layers.projectLayers.findLast((layer) => layer.config?.provider?.[providerId] !== undefined || layer.config?.providers?.[providerId] !== undefined)?.path || paths.projectPath || null },
35
35
  custom: { exists: customExists, path: paths.customPath }
36
36
  }
37
37
  };
@@ -44,17 +44,20 @@ function removeProviderConfig(providerId, workingDirectory, scope = 'user') {
44
44
 
45
45
  const layers = readConfigLayers(workingDirectory);
46
46
  let targetPath = layers.paths.userPath;
47
+ const sources = getProviderSources(providerId, workingDirectory).sources;
47
48
 
48
49
  if (scope === 'project') {
49
50
  if (!workingDirectory) {
50
51
  throw new Error('Working directory is required for project scope');
51
52
  }
52
- targetPath = layers.paths.projectPath || targetPath;
53
+ targetPath = sources.project.path || targetPath;
53
54
  } else if (scope === 'custom') {
54
55
  if (!layers.paths.customPath) {
55
56
  return false;
56
57
  }
57
58
  targetPath = layers.paths.customPath;
59
+ } else {
60
+ targetPath = sources.user.path;
58
61
  }
59
62
 
60
63
  const targetConfig = getConfigForPath(layers, targetPath);
@@ -1,4 +1,5 @@
1
1
  import { createProxyMiddleware } from 'http-proxy-middleware';
2
+ import { isSensitiveCredentialResponse } from './credential-boundary.js';
2
3
 
3
4
  import {
4
5
  applyForwardProxyResponseHeaders,
@@ -244,6 +245,12 @@ export const resolveSessionTurnAdmissionRequest = (req) => {
244
245
  };
245
246
 
246
247
  export const registerOpenCodeProxy = (app, deps) => {
248
+ app.use((req, res, next) => {
249
+ if (isSensitiveCredentialResponse(req.method, req.originalUrl || req.url)) {
250
+ return res.status(403).json({ code: 'credential_response_refused', error: 'Provider credential values are host-only.' });
251
+ }
252
+ return next();
253
+ });
247
254
  const {
248
255
  fs,
249
256
  os,
@@ -57,6 +57,19 @@ const mountProxy = () => {
57
57
  return app;
58
58
  };
59
59
 
60
+ describe('credential response boundary', () => {
61
+ it.each(['/api/credential', '/api/credential/', '/api/%63redential?directory=/repo'])('refuses raw credential list %s before upstream', async (path) => {
62
+ const fetchSpy = vi.fn();
63
+ globalThis.fetch = fetchSpy;
64
+ const response = await request(mountProxy()).get(path).expect(403);
65
+ expect(response.body.code).toBe('credential_response_refused');
66
+ expect(fetchSpy).not.toHaveBeenCalled();
67
+ });
68
+ it('refuses credential create replies which echo values', async () => {
69
+ await request(mountProxy()).post('/api/credential').send({}).expect(403);
70
+ });
71
+ });
72
+
60
73
  const sseBody = (blocks) => {
61
74
  const encoder = new TextEncoder();
62
75
  const text = blocks.join('');
@@ -709,7 +709,7 @@ export const registerOpenCodeRoutes = (app, dependencies) => {
709
709
 
710
710
  const sources = getProviderSources(providerId, directory);
711
711
  const { getProviderAuth } = await getAuthLibrary();
712
- const auth = getProviderAuth(providerId);
712
+ const auth = await getProviderAuth(providerId);
713
713
  sources.sources.auth.exists = Boolean(auth);
714
714
 
715
715
  return res.json({
@@ -753,12 +753,12 @@ export const registerOpenCodeRoutes = (app, dependencies) => {
753
753
  let removed = false;
754
754
  if (scope === 'auth') {
755
755
  const { removeProviderAuth } = await getAuthLibrary();
756
- removed = removeProviderAuth(providerId);
756
+ removed = await removeProviderAuth(providerId);
757
757
  } else if (scope === 'user' || scope === 'project' || scope === 'custom') {
758
758
  removed = removeProviderConfig(providerId, directory, scope);
759
759
  } else if (scope === 'all') {
760
760
  const { removeProviderAuth } = await getAuthLibrary();
761
- const authRemoved = removeProviderAuth(providerId);
761
+ const authRemoved = await removeProviderAuth(providerId);
762
762
  const userRemoved = removeProviderConfig(providerId, directory, 'user');
763
763
  const projectRemoved = directory ? removeProviderConfig(providerId, directory, 'project') : false;
764
764
  const customRemoved = removeProviderConfig(providerId, directory, 'custom');
@@ -767,14 +767,14 @@ export const registerOpenCodeRoutes = (app, dependencies) => {
767
767
  return res.status(400).json({ error: 'Invalid scope' });
768
768
  }
769
769
 
770
- if (removed) {
770
+ if (removed && scope !== 'auth') {
771
771
  await refreshOpenCodeAfterConfigChange(`provider ${providerId} disconnected (${scope})`);
772
772
  }
773
773
 
774
774
  return res.json({
775
775
  success: true,
776
776
  removed,
777
- requiresReload: removed,
777
+ requiresReload: removed && scope !== 'auth',
778
778
  message: removed ? 'Provider disconnected successfully' : 'Provider was not connected',
779
779
  reloadDelayMs: removed ? clientReloadDelayMs : undefined,
780
780
  });
@@ -233,39 +233,39 @@ describe('opencode2 upgrade pin (ticket 12)', () => {
233
233
  });
234
234
 
235
235
  it('upgrades a shared service using the owned cache and verifies the running version', async () => {
236
- const binary = '/cache/opencode-cli/2.0.14/opencode';
236
+ const binary = '/cache/opencode-cli/2.0.23/opencode';
237
237
  vi.mocked(installPinnedOpenCode2Cli).mockResolvedValueOnce(binary);
238
- vi.mocked(readOpenCode2BinaryVersion).mockReturnValueOnce('2.0.14');
239
- vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify({ version: '2.0.14', pid: 42 }))));
238
+ vi.mocked(readOpenCode2BinaryVersion).mockReturnValueOnce('2.0.23');
239
+ vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify({ version: '2.0.23', pid: 42 }))));
240
240
  let resolvedBinary = '/usr/local/bin/opencode';
241
241
  const restartOpenCode = vi.fn(async () => {});
242
242
  const { app } = createUpgradeApp({
243
243
  getIsExternalOpenCode: () => true,
244
244
  getIsSharedOpenCodeService: () => true,
245
245
  getResolvedOpenCodeBinary: () => resolvedBinary,
246
- getOpenCodeCliVersion: () => '2.0.14',
246
+ getOpenCodeCliVersion: () => '2.0.23',
247
247
  forceResolvedOpenCodeBinary: (value) => { resolvedBinary = value; },
248
248
  restartOpenCode,
249
249
  waitForOpenCodeReady: vi.fn(),
250
250
  openchamberDataDir: '/cache',
251
251
  });
252
252
 
253
- const response = await request(app).post('/api/opencode/upgrade').send({ target: '2.0.14' }).expect(200);
253
+ const response = await request(app).post('/api/opencode/upgrade').send({ target: '2.0.23' }).expect(200);
254
254
  expect(restartOpenCode).toHaveBeenCalledWith({ binaryPath: binary });
255
- expect(response.body).toMatchObject({ success: true, version: '2.0.14', ownership: 'shared-service' });
255
+ expect(response.body).toMatchObject({ success: true, version: '2.0.23', ownership: 'shared-service' });
256
256
  });
257
257
 
258
- it('discovers the remote release and updates a shared 2.0.16 service to 2.0.18', async () => {
258
+ it('discovers the remote release and updates a shared 2.0.16 service to 2.0.23', async () => {
259
259
  let serveVersion = '2.0.16';
260
260
  let binary = '/usr/local/bin/opencode';
261
- const installed = '/cache/opencode-cli/2.0.18/opencode';
261
+ const installed = '/cache/opencode-cli/2.0.23/opencode';
262
262
  const fetchMock = vi.fn(async (url) => new Response(JSON.stringify({
263
- version: String(url).includes('registry.npmjs.org') ? '2.0.18' : serveVersion,
263
+ version: String(url).includes('registry.npmjs.org') ? '2.0.23' : serveVersion,
264
264
  })));
265
265
  vi.stubGlobal('fetch', fetchMock);
266
- vi.mocked(readOpenCode2BinaryVersion).mockReturnValue('2.0.18');
266
+ vi.mocked(readOpenCode2BinaryVersion).mockReturnValue('2.0.23');
267
267
  vi.mocked(installPinnedOpenCode2Cli).mockResolvedValueOnce(installed);
268
- const restartOpenCode = vi.fn(async () => { serveVersion = '2.0.18'; });
268
+ const restartOpenCode = vi.fn(async () => { serveVersion = '2.0.23'; });
269
269
  const { app } = createUpgradeApp({
270
270
  discoverOpenCodeUpdate: createOpenCodeUpdateDiscovery(),
271
271
  getIsExternalOpenCode: () => true,
@@ -277,12 +277,12 @@ describe('opencode2 upgrade pin (ticket 12)', () => {
277
277
  openchamberDataDir: '/cache',
278
278
  });
279
279
  const before = await request(app).get('/api/opencode/upgrade-status').expect(200);
280
- expect(before.body).toMatchObject({ available: true, serveVersion: '2.0.16', targetVersion: '2.0.18', canManage: true });
280
+ expect(before.body).toMatchObject({ available: true, serveVersion: '2.0.16', targetVersion: '2.0.23', canManage: true });
281
281
  const result = await request(app).post('/api/opencode/upgrade').send({}).expect(200);
282
- expect(result.body).toMatchObject({ success: true, restarted: true, version: '2.0.18' });
282
+ expect(result.body).toMatchObject({ success: true, restarted: true, version: '2.0.23' });
283
283
  expect(restartOpenCode).toHaveBeenCalledWith({ binaryPath: installed });
284
284
  const after = await request(app).get('/api/opencode/upgrade-status').expect(200);
285
- expect(after.body).toMatchObject({ available: false, serveVersion: '2.0.18' });
285
+ expect(after.body).toMatchObject({ available: false, serveVersion: '2.0.23' });
286
286
  expect(fetchMock.mock.calls.filter(([url]) => String(url).includes('registry.npmjs.org'))).toHaveLength(1);
287
287
  vi.mocked(readOpenCode2BinaryVersion).mockReset();
288
288
  });
@@ -385,7 +385,7 @@ describe('runtime contract routes (ticket 11)', () => {
385
385
  ok: true,
386
386
  status: 200,
387
387
  statusText: 'OK',
388
- json: async () => ({ version: '2.0.12', pid: 1 }),
388
+ json: async () => ({ version: '2.0.20', pid: 1 }),
389
389
  })));
390
390
  const { app } = createContractApp();
391
391
 
@@ -395,7 +395,7 @@ describe('runtime contract routes (ticket 11)', () => {
395
395
  expect(health.body.executionAllowed).toBe(true);
396
396
 
397
397
  const contract = await request(app).get('/api/opencode/contract').expect(200);
398
- expect(contract.body.serveVersion).toBe('2.0.12');
398
+ expect(contract.body.serveVersion).toBe('2.0.20');
399
399
  expect(contract.body.capabilities['core.protocol'].available).toBe(true);
400
400
  });
401
401
 
@@ -439,7 +439,7 @@ describe('GET /api/opencode/health', () => {
439
439
  status: 200,
440
440
  statusText: 'OK',
441
441
  json: async () => ({
442
- version: '2.0.12',
442
+ version: '2.0.20',
443
443
  pid: 4242,
444
444
  urls: { api: 'http://127.0.0.1:4096' },
445
445
  paths: { config: '/tmp/config', data: '/tmp/data' },
@@ -453,7 +453,7 @@ describe('GET /api/opencode/health', () => {
453
453
 
454
454
  expect(response.body).toMatchObject({
455
455
  healthy: true,
456
- version: '2.0.12',
456
+ version: '2.0.20',
457
457
  executionAllowed: true,
458
458
  protocolCompatible: true,
459
459
  });