@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
@@ -21,6 +21,20 @@ import {
21
21
  stripIncludeReasoningParam,
22
22
  } from '../event-stream/reasoning-projection.js';
23
23
 
24
+ /**
25
+ * v2 protocol mounts every JSON/SSE route under `/api`
26
+ * (`@opencode-ai/protocol` groups). Host traffic is already `/api/*`.
27
+ * Express `app.use('/api', …)` strips that prefix from `req.url`; put it back
28
+ * before talking to opencode2. Passing a root `/session` path to v2 returns
29
+ * the bundled Web UI HTML (`text/html`) and the SDK throws UnsupportedContentType.
30
+ */
31
+ export const toUpstreamOpenCodeApiPath = (requestUrl) => {
32
+ const raw = typeof requestUrl === 'string' ? requestUrl : '';
33
+ if (!raw || raw === '/') return '/api';
34
+ if (raw.startsWith('/api/') || raw === '/api' || raw.startsWith('/api?')) return raw;
35
+ return raw.startsWith('/') ? `/api${raw}` : `/api/${raw}`;
36
+ };
37
+
24
38
  export const createDirectoryQueryCanonicalizer = ({ realpath, ...cacheOptions } = {}) => {
25
39
  const realpathCache = createRealpathCache({ fallbackOnError: true, realpath, ...cacheOptions });
26
40
 
@@ -183,10 +197,19 @@ const sanitizeSessionListItem = (session) => {
183
197
  };
184
198
 
185
199
  const sanitizeSessionListPayload = (payload) => {
186
- if (!Array.isArray(payload)) {
187
- return payload;
200
+ if (Array.isArray(payload)) {
201
+ return payload.map((session) => sanitizeSessionListItem(session));
188
202
  }
189
- return payload.map((session) => sanitizeSessionListItem(session));
203
+ if (payload && typeof payload === 'object' && Array.isArray(payload.data)) {
204
+ return { ...payload, data: payload.data.map((session) => sanitizeSessionListItem(session)) };
205
+ }
206
+ return payload;
207
+ };
208
+
209
+ const sessionListRecords = (payload) => {
210
+ if (Array.isArray(payload)) return payload;
211
+ if (payload && typeof payload === 'object' && Array.isArray(payload.data)) return payload.data;
212
+ return null;
190
213
  };
191
214
 
192
215
  export const isInteractiveSessionRequest = (method, requestUrl) => {
@@ -226,8 +249,56 @@ export const registerOpenCodeProxy = (app, deps) => {
226
249
  ensureOpenCodeApiPrefix,
227
250
  onInteractiveSessionRequest,
228
251
  onSessionTurnAdmission,
252
+ // OpenCode 2.x has no session-metadata update route. When the Host store is
253
+ // wired, fold its per-session metadata onto list/detail responses so
254
+ // clients keep reading `session.metadata` where they always did.
255
+ getStoredSessionMetadata = null,
229
256
  } = deps;
230
257
 
258
+ /**
259
+ * `{ [sessionID]: metadata }` for the current instance, or `null` when the
260
+ * store cannot answer. `null` means "unknown", and an unknown answer leaves
261
+ * the upstream record untouched — never rewrite a session as empty metadata.
262
+ */
263
+ const readStoredSessionMetadata = async () => {
264
+ if (typeof getStoredSessionMetadata !== 'function') return null;
265
+ try {
266
+ const stored = await getStoredSessionMetadata();
267
+ return stored && typeof stored === 'object' ? stored : null;
268
+ } catch (error) {
269
+ console.warn('[proxy] session metadata unavailable:', error?.message ?? error);
270
+ return null;
271
+ }
272
+ };
273
+
274
+ /**
275
+ * OpenChamber's stored metadata wins per key: OpenCode only ever saw what was
276
+ * set at create time, and everything written since lives on our side.
277
+ */
278
+ const withStoredMetadata = (session, stored) => {
279
+ if (!session || typeof session !== 'object' || typeof session.id !== 'string') return session;
280
+ const ours = stored[session.id];
281
+ if (!ours || typeof ours !== 'object' || Array.isArray(ours)) return session;
282
+ const theirs = session.metadata && typeof session.metadata === 'object' && !Array.isArray(session.metadata)
283
+ ? session.metadata
284
+ : {};
285
+ return { ...session, metadata: { ...theirs, ...ours } };
286
+ };
287
+
288
+ const overlaySession = (session, stored) => {
289
+ if (!stored) return session;
290
+ return withStoredMetadata(session, stored);
291
+ };
292
+
293
+ const overlayOwnedStateOnList = async (payload) => {
294
+ const records = sessionListRecords(payload);
295
+ if (!records) return payload;
296
+ const stored = await readStoredSessionMetadata();
297
+ if (!stored) return payload;
298
+ const overlaid = records.map((session) => overlaySession(session, stored));
299
+ return Array.isArray(payload) ? overlaid : { ...payload, data: overlaid };
300
+ };
301
+
231
302
  if (app.get('opencodeProxyConfigured')) {
232
303
  return;
233
304
  }
@@ -419,7 +490,7 @@ export const registerOpenCodeProxy = (app, deps) => {
419
490
  try {
420
491
  // Strip includeReasoning before building the upstream OpenCode path.
421
492
  const strippedUrl = stripIncludeReasoningParam(requestUrl);
422
- const upstreamPath = strippedUrl.startsWith('/api') ? strippedUrl.slice(4) || '/' : strippedUrl;
493
+ const upstreamPath = toUpstreamOpenCodeApiPath(strippedUrl);
423
494
  const headers = normalizeForwardedDirectoryHeaders(
424
495
  collectForwardProxyHeaders(req.headers, getOpenCodeAuthHeaders())
425
496
  );
@@ -625,8 +696,7 @@ export const registerOpenCodeProxy = (app, deps) => {
625
696
  const requestUrl = typeof req.originalUrl === 'string' && req.originalUrl.length > 0
626
697
  ? req.originalUrl
627
698
  : (typeof req.url === 'string' ? req.url : '');
628
- const upstreamPathRaw = requestUrl.startsWith('/api') ? requestUrl.slice(4) || '/' : requestUrl;
629
- return canonicalizeDirectoryQuery(upstreamPathRaw);
699
+ return canonicalizeDirectoryQuery(toUpstreamOpenCodeApiPath(requestUrl));
630
700
  };
631
701
 
632
702
  const forwardSanitizedSessionListRequest = async (req, res, next, logLabel) => {
@@ -643,14 +713,14 @@ export const registerOpenCodeProxy = (app, deps) => {
643
713
  return;
644
714
  }
645
715
 
646
- if (result.parseError || !Array.isArray(result.payload)) {
716
+ if (result.parseError || !sessionListRecords(result.payload)) {
647
717
  res.setHeader('content-type', result.contentType);
648
718
  res.end(result.bodyText);
649
719
  return;
650
720
  }
651
721
 
652
722
  res.setHeader('content-type', result.contentType);
653
- res.json(sanitizeSessionListPayload(result.payload));
723
+ res.json(await overlayOwnedStateOnList(sanitizeSessionListPayload(result.payload)));
654
724
  } catch (error) {
655
725
  if (isAbortError(error)) {
656
726
  return;
@@ -735,7 +805,7 @@ export const registerOpenCodeProxy = (app, deps) => {
735
805
  const READINESS_HOLD_POLL_MS = 75;
736
806
  const READINESS_HOLD_MAX_MS = 6000;
737
807
  const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
738
- const isStillWaiting = (runtimeState) => {
808
+ const isProcessWarmingUp = (runtimeState) => {
739
809
  const waitElapsed = runtimeState.openCodeNotReadySince === 0 ? 0 : Date.now() - runtimeState.openCodeNotReadySince;
740
810
  return (
741
811
  (!runtimeState.isOpenCodeReady && (runtimeState.openCodeNotReadySince === 0 || waitElapsed < OPEN_CODE_READY_GRACE_MS)) ||
@@ -744,6 +814,18 @@ export const registerOpenCodeProxy = (app, deps) => {
744
814
  );
745
815
  };
746
816
 
817
+ // Process warmup keeps the existing grace window. V1 migration is separate:
818
+ // admitTranscript=false holds with no grace until the gate admits or the
819
+ // client cancels. After grace, a still-running migration must not next()
820
+ // an empty session list through as an authoritative success.
821
+ const isMigrationBlockingTranscript = (runtimeState) => (
822
+ runtimeState?.v1Migration != null && runtimeState.v1Migration.admitTranscript !== true
823
+ );
824
+
825
+ const shouldHoldProxiedRequest = (runtimeState) => (
826
+ isProcessWarmingUp(runtimeState) || isMigrationBlockingTranscript(runtimeState)
827
+ );
828
+
747
829
  app.use('/api', async (req, res, next) => {
748
830
  if (
749
831
  req.path.startsWith('/themes/custom') ||
@@ -758,18 +840,22 @@ export const registerOpenCodeProxy = (app, deps) => {
758
840
  return next();
759
841
  }
760
842
 
761
- if (!isStillWaiting(getRuntime())) {
843
+ if (!shouldHoldProxiedRequest(getRuntime())) {
762
844
  return next();
763
845
  }
764
846
 
765
- const deadline = Date.now() + Math.min(OPEN_CODE_READY_GRACE_MS, READINESS_HOLD_MAX_MS);
766
- while (Date.now() < deadline) {
847
+ const processDeadline = Date.now() + Math.min(OPEN_CODE_READY_GRACE_MS, READINESS_HOLD_MAX_MS);
848
+ while (true) {
767
849
  // Client gave up (closed/aborted) — stop holding.
768
850
  if (res.writableEnded || req.aborted) return;
769
- await sleep(READINESS_HOLD_POLL_MS);
770
- if (!isStillWaiting(getRuntime())) {
851
+ const runtimeState = getRuntime();
852
+ if (!shouldHoldProxiedRequest(runtimeState)) {
771
853
  return next();
772
854
  }
855
+ if (!isMigrationBlockingTranscript(runtimeState) && Date.now() >= processDeadline) {
856
+ break;
857
+ }
858
+ await sleep(READINESS_HOLD_POLL_MS);
773
859
  }
774
860
 
775
861
  if (!res.headersSent) {
@@ -852,7 +938,7 @@ export const registerOpenCodeProxy = (app, deps) => {
852
938
  return bTime - aTime;
853
939
  });
854
940
  console.log(`[SessionMerge] ${globalSessions?.length || 0} global + ${extraSessions.length} extra = ${merged.length} total`);
855
- return res.json(sanitizeSessionListPayload(merged));
941
+ return res.json(await overlayOwnedStateOnList(sanitizeSessionListPayload(merged)));
856
942
  } catch (error) {
857
943
  console.log(`[SessionMerge] Error: ${error.message}`);
858
944
  return res.status(500).json({ error: error.message || 'Failed to merge Windows sessions' });
@@ -864,6 +950,55 @@ export const registerOpenCodeProxy = (app, deps) => {
864
950
  return forwardSanitizedSessionListRequest(req, res, next, 'session.list');
865
951
  });
866
952
 
953
+ // One session: the same metadata overlay as the list, so a detail read agrees
954
+ // with the list it came from. Everything else about the record is forwarded
955
+ // untouched. Without a store, fall through to the generic proxy.
956
+ app.get('/api/session/:sessionID', async (req, res, next) => {
957
+ if (typeof getStoredSessionMetadata !== 'function') return next();
958
+ // Nested routes (message, children, …) are registered separately; Express
959
+ // still matches this pattern for bare session ids only when no further
960
+ // segment is present — but be defensive if a child path slips through.
961
+ if (req.path.includes('/message') || String(req.params.sessionID || '').includes('/')) {
962
+ return next();
963
+ }
964
+ try {
965
+ const upstreamPath = await getRequestUpstreamPath(req);
966
+ const result = await fetchSessionListPayload(upstreamPath, { req });
967
+
968
+ res.status(result.upstream.status);
969
+ applyForwardProxyResponseHeaders(result.upstream.headers, res);
970
+ res.setHeader('content-type', result.contentType);
971
+
972
+ const record = result.isJson && !result.parseError ? result.payload : null;
973
+ const session = record && typeof record === 'object' && !Array.isArray(record)
974
+ ? (record.data && typeof record.data === 'object' && !Array.isArray(record.data) ? record.data : record)
975
+ : null;
976
+ if (!session || typeof session.id !== 'string') {
977
+ res.end(result.bodyText);
978
+ return;
979
+ }
980
+
981
+ const stored = await readStoredSessionMetadata();
982
+ if (!stored) {
983
+ res.end(result.bodyText);
984
+ return;
985
+ }
986
+
987
+ const overlaid = overlaySession(session, stored);
988
+ res.json(record.data && typeof record.data === 'object' && !Array.isArray(record.data)
989
+ ? { ...record, data: overlaid }
990
+ : overlaid);
991
+ } catch (error) {
992
+ if (isAbortError(error)) return;
993
+ console.error('[proxy] OpenCode session.get proxy error:', error?.message ?? error);
994
+ if (!res.headersSent) {
995
+ res.status(503).json({ error: 'OpenCode service unavailable' });
996
+ return;
997
+ }
998
+ next(error);
999
+ }
1000
+ });
1001
+
867
1002
  // Official session.messages list (not exact message — that is owned by
868
1003
  // session-turn-pages and registered before this proxy).
869
1004
  app.get('/api/session/:sessionID/message', (req, res, next) => {
@@ -881,7 +1016,9 @@ export const registerOpenCodeProxy = (app, deps) => {
881
1016
  const apiProxy = createProxyMiddleware({
882
1017
  target: resolveProxyTarget(),
883
1018
  changeOrigin: true,
884
- pathRewrite: { '^/api': '' },
1019
+ // Express already stripped the `/api` mount. Restore it so v2 receives
1020
+ // `/api/agent` rather than `/agent` (which serves the Web UI HTML).
1021
+ pathRewrite: (pathValue) => toUpstreamOpenCodeApiPath(pathValue),
885
1022
  timeout: PROXY_REQUEST_TIMEOUT_MS,
886
1023
  proxyTimeout: PROXY_REQUEST_TIMEOUT_MS,
887
1024
  // Dynamic target — port can change after restart
@@ -8,8 +8,21 @@ import {
8
8
  normalizeForwardedDirectoryHeaders,
9
9
  registerOpenCodeProxy,
10
10
  resolveSessionTurnAdmissionRequest,
11
+ toUpstreamOpenCodeApiPath,
11
12
  } from './proxy.js';
12
13
 
14
+ describe('toUpstreamOpenCodeApiPath', () => {
15
+ it('keeps v2 /api paths and restores the prefix Express strips at the /api mount', () => {
16
+ expect(toUpstreamOpenCodeApiPath('/api/agent')).toBe('/api/agent');
17
+ expect(toUpstreamOpenCodeApiPath('/api/session?directory=/repo')).toBe('/api/session?directory=/repo');
18
+ expect(toUpstreamOpenCodeApiPath('/api')).toBe('/api');
19
+ expect(toUpstreamOpenCodeApiPath('/agent')).toBe('/api/agent');
20
+ expect(toUpstreamOpenCodeApiPath('/session/ses_1/message')).toBe('/api/session/ses_1/message');
21
+ expect(toUpstreamOpenCodeApiPath('/')).toBe('/api');
22
+ expect(toUpstreamOpenCodeApiPath('')).toBe('/api');
23
+ });
24
+ });
25
+
13
26
  const originalFetch = globalThis.fetch;
14
27
 
15
28
  afterEach(() => {
@@ -300,3 +313,110 @@ describe('registerOpenCodeProxy reasoning projection routes', () => {
300
313
  expect(res.body[0].parts[0].text).toBe('keep-me');
301
314
  });
302
315
  });
316
+
317
+ describe('registerOpenCodeProxy session metadata overlay', () => {
318
+ const mountProxyWithMetadata = (getStoredSessionMetadata) => {
319
+ const app = express();
320
+ registerOpenCodeProxy(app, {
321
+ fs: { promises: { realpath: async (v) => v } },
322
+ os: {},
323
+ path: {},
324
+ OPEN_CODE_READY_GRACE_MS: 1000,
325
+ LONG_REQUEST_TIMEOUT_MS: 60_000,
326
+ SSE_UPSTREAM_CONNECT_TIMEOUT_MS: 5_000,
327
+ SSE_UPSTREAM_STALL_TIMEOUT_MS: 5_000,
328
+ getRuntime: readyRuntime,
329
+ getOpenCodeAuthHeaders: () => ({}),
330
+ buildOpenCodeUrl: (path) => `http://127.0.0.1:4096${path}`,
331
+ ensureOpenCodeApiPrefix: () => {},
332
+ getStoredSessionMetadata,
333
+ });
334
+ return app;
335
+ };
336
+
337
+ it('merges Host store metadata onto session list and detail (store keys win)', async () => {
338
+ globalThis.fetch = vi.fn(async (url) => {
339
+ const path = String(url).split('?')[0];
340
+ if (path.endsWith('/api/session') || path.endsWith('/session')) {
341
+ return {
342
+ ok: true,
343
+ status: 200,
344
+ headers: new Headers({ 'content-type': 'application/json' }),
345
+ text: async () => JSON.stringify([
346
+ {
347
+ id: 'ses_1',
348
+ title: 'Alpha',
349
+ metadata: { fromOpenCode: true, shared: 'theirs' },
350
+ },
351
+ { id: 'ses_2', title: 'Beta' },
352
+ ]),
353
+ };
354
+ }
355
+ if (path.includes('/session/ses_1')) {
356
+ return {
357
+ ok: true,
358
+ status: 200,
359
+ headers: new Headers({ 'content-type': 'application/json' }),
360
+ text: async () => JSON.stringify({
361
+ id: 'ses_1',
362
+ title: 'Alpha',
363
+ metadata: { fromOpenCode: true, shared: 'theirs' },
364
+ }),
365
+ };
366
+ }
367
+ return {
368
+ ok: false,
369
+ status: 404,
370
+ headers: new Headers({ 'content-type': 'application/json' }),
371
+ text: async () => JSON.stringify({ error: 'not found' }),
372
+ };
373
+ });
374
+
375
+ const app = mountProxyWithMetadata(async () => ({
376
+ ses_1: { openchamber: { goal: { status: 'active' } }, shared: 'ours' },
377
+ }));
378
+
379
+ const list = await request(app).get('/api/session');
380
+ expect(list.status).toBe(200);
381
+ expect(list.body[0]).toMatchObject({
382
+ id: 'ses_1',
383
+ metadata: {
384
+ fromOpenCode: true,
385
+ shared: 'ours',
386
+ openchamber: { goal: { status: 'active' } },
387
+ },
388
+ });
389
+ expect(list.body[1].metadata).toBeUndefined();
390
+
391
+ const detail = await request(app).get('/api/session/ses_1');
392
+ expect(detail.status).toBe(200);
393
+ expect(detail.body).toMatchObject({
394
+ id: 'ses_1',
395
+ metadata: {
396
+ fromOpenCode: true,
397
+ shared: 'ours',
398
+ openchamber: { goal: { status: 'active' } },
399
+ },
400
+ });
401
+ });
402
+
403
+ it('leaves upstream records untouched when the store throws', async () => {
404
+ vi.spyOn(console, 'warn').mockImplementation(() => {});
405
+ globalThis.fetch = vi.fn(async () => ({
406
+ ok: true,
407
+ status: 200,
408
+ headers: new Headers({ 'content-type': 'application/json' }),
409
+ text: async () => JSON.stringify([
410
+ { id: 'ses_1', title: 'Alpha', metadata: { fromOpenCode: true } },
411
+ ]),
412
+ }));
413
+
414
+ const app = mountProxyWithMetadata(async () => {
415
+ throw new Error('store down');
416
+ });
417
+
418
+ const list = await request(app).get('/api/session');
419
+ expect(list.status).toBe(200);
420
+ expect(list.body[0]).toEqual({ id: 'ses_1', title: 'Alpha', metadata: { fromOpenCode: true } });
421
+ });
422
+ });
@@ -1,5 +1,6 @@
1
1
  import { createProjectIdFromPath } from '../projects/project-id.js';
2
2
  import { projectBootstrapSettingsResponse } from './settings-helpers.js';
3
+ import { PINNED_OPENCODE2_VERSION, isOpenCode1xVersion, resolveOpenCode2UpgradeTarget } from './opencode2-pin.js';
3
4
  import fs from 'fs';
4
5
  import os from 'os';
5
6
  import path from 'path';
@@ -22,6 +23,7 @@ export const registerOpenCodeRoutes = (app, dependencies) => {
22
23
  buildOpenCodeUrl,
23
24
  getOpenCodeAuthHeaders,
24
25
  onSettingsPersisted,
26
+ getIsExternalOpenCode = () => false,
25
27
  } = dependencies;
26
28
 
27
29
  let authLibrary = null;
@@ -50,7 +52,7 @@ export const registerOpenCodeRoutes = (app, dependencies) => {
50
52
  };
51
53
 
52
54
  const readOpenCodeCurrentVersion = async () => {
53
- const healthResponse = await fetch(buildOpenCodeUrl('/global/health', ''), {
55
+ const healthResponse = await fetch(buildOpenCodeUrl('/api/info', ''), {
54
56
  method: 'GET',
55
57
  headers: { Accept: 'application/json', ...getOpenCodeAuthHeaders() },
56
58
  });
@@ -183,37 +185,30 @@ export const registerOpenCodeRoutes = (app, dependencies) => {
183
185
 
184
186
  app.post('/api/opencode/upgrade', async (req, res) => {
185
187
  try {
186
- if (await isBundledOpenCodeBinaryActive()) {
188
+ if (getIsExternalOpenCode()) {
187
189
  return res.status(409).json({
188
190
  success: false,
189
- error: 'OpenCode is bundled with OpenChamber Desktop and cannot be upgraded separately.',
191
+ error: 'This is your own OpenCode serve. OpenChamber will not upgrade or restart it.',
190
192
  });
191
193
  }
192
194
 
193
- const target = normalizeOpenCodeUpgradeTarget(req.body?.target);
194
- if (!target) {
195
- return res.status(400).json({
195
+ if (await isBundledOpenCodeBinaryActive()) {
196
+ return res.status(409).json({
196
197
  success: false,
197
- error: 'OpenCode upgrade requires a semantic version target',
198
+ error: 'OpenCode is bundled with OpenChamber Desktop and cannot be upgraded separately.',
198
199
  });
199
200
  }
200
201
 
201
- const response = await fetch(buildOpenCodeUrl('/global/upgrade', ''), {
202
- method: 'POST',
203
- headers: {
204
- 'Content-Type': 'application/json',
205
- Accept: 'application/json',
206
- ...getOpenCodeAuthHeaders(),
207
- },
208
- body: JSON.stringify({ target }),
209
- });
210
- const payload = await response.json().catch(() => null);
211
- if (!response.ok) {
212
- return res.status(response.status).json({
202
+ const rawTarget = typeof req.body?.target === 'string' && req.body.target.trim().length > 0
203
+ ? req.body.target.trim()
204
+ : undefined;
205
+ if (isOpenCode1xVersion(rawTarget)) {
206
+ return res.status(400).json({
213
207
  success: false,
214
- error: readOpenCodeUpgradeError(payload, response.statusText || 'Failed to upgrade OpenCode'),
208
+ error: 'OpenCode upgrade refuses 1.x targets. Only pinned opencode2 is allowed.',
215
209
  });
216
210
  }
211
+ const target = resolveOpenCode2UpgradeTarget(rawTarget);
217
212
 
218
213
  try {
219
214
  await refreshOpenCodeAfterConfigChange('OpenCode upgrade');
@@ -227,7 +222,7 @@ export const registerOpenCodeRoutes = (app, dependencies) => {
227
222
  });
228
223
  }
229
224
 
230
- return res.json({ ...(payload ?? { success: true }), restarted: true });
225
+ return res.json({ success: true, restarted: true, version: target, pinned: true });
231
226
  } catch (error) {
232
227
  console.error('Failed to upgrade OpenCode:', error);
233
228
  return res.status(500).json({
@@ -249,13 +244,10 @@ export const registerOpenCodeRoutes = (app, dependencies) => {
249
244
  });
250
245
  }
251
246
 
252
- const [healthResponse, latestVersion] = await Promise.all([
253
- fetch(buildOpenCodeUrl('/global/health', ''), {
254
- method: 'GET',
255
- headers: { Accept: 'application/json', ...getOpenCodeAuthHeaders() },
256
- }),
257
- fetchLatestOpenCodeVersion(),
258
- ]);
247
+ const healthResponse = await fetch(buildOpenCodeUrl('/api/info', ''), {
248
+ method: 'GET',
249
+ headers: { Accept: 'application/json', ...getOpenCodeAuthHeaders() },
250
+ });
259
251
  const health = await healthResponse.json().catch(() => null);
260
252
  if (!healthResponse.ok) {
261
253
  return res.status(healthResponse.status).json({
@@ -264,10 +256,11 @@ export const registerOpenCodeRoutes = (app, dependencies) => {
264
256
  });
265
257
  }
266
258
  const currentVersion = typeof health?.version === 'string' ? health.version.replace(/^v/, '') : null;
259
+ const latestVersion = isOpenCode1xVersion(PINNED_OPENCODE2_VERSION) ? null : PINNED_OPENCODE2_VERSION;
267
260
  if (!currentVersion || !latestVersion) {
268
- return res.json({ available: null, currentVersion, latestVersion: latestVersion || null });
261
+ return res.json({ available: null, currentVersion, latestVersion });
269
262
  }
270
- const available = compareVersions(latestVersion, currentVersion) > 0;
263
+ const available = !isOpenCode1xVersion(currentVersion) && compareVersions(latestVersion, currentVersion) > 0;
271
264
  return res.json({
272
265
  available,
273
266
  currentVersion,
@@ -283,7 +276,7 @@ export const registerOpenCodeRoutes = (app, dependencies) => {
283
276
 
284
277
  app.get('/api/opencode/health', async (_req, res) => {
285
278
  try {
286
- const healthResponse = await fetch(buildOpenCodeUrl('/global/health', ''), {
279
+ const healthResponse = await fetch(buildOpenCodeUrl('/api/info', ''), {
287
280
  method: 'GET',
288
281
  headers: { Accept: 'application/json', ...getOpenCodeAuthHeaders() },
289
282
  signal: AbortSignal.timeout(4_000),
@@ -306,7 +299,7 @@ export const registerOpenCodeRoutes = (app, dependencies) => {
306
299
 
307
300
  app.get('/api/opencode/version', async (_req, res) => {
308
301
  try {
309
- const healthResponse = await fetch(buildOpenCodeUrl('/global/health', ''), {
302
+ const healthResponse = await fetch(buildOpenCodeUrl('/api/info', ''), {
310
303
  method: 'GET',
311
304
  headers: { Accept: 'application/json', ...getOpenCodeAuthHeaders() },
312
305
  });
@@ -4,6 +4,7 @@ import request from 'supertest';
4
4
  import fs from 'fs';
5
5
 
6
6
  import { registerOpenCodeRoutes } from './routes.js';
7
+ import { isOpenCode1xVersion } from './opencode2-pin.js';
7
8
 
8
9
  const createDependencies = ({ formatSettingsResponse }) => ({
9
10
  crypto: {},
@@ -126,79 +127,77 @@ describe('behavior AGENTS.md route', () => {
126
127
  });
127
128
  });
128
129
 
129
- describe('opencode upgrade route', () => {
130
- const originalFetch = globalThis.fetch;
131
-
132
- afterEach(() => {
133
- globalThis.fetch = originalFetch;
134
- });
135
-
136
- const jsonResponse = (body, status = 200, statusText = '') =>
137
- new Response(JSON.stringify(body), {
138
- status,
139
- statusText,
140
- headers: { 'Content-Type': 'application/json' },
141
- });
142
-
143
- const createUpgradeApp = () => {
130
+ describe('opencode2 upgrade pin (ticket 12)', () => {
131
+ const createUpgradeApp = (overrides = {}) => {
144
132
  const app = express();
145
133
  app.use(express.json());
146
- const deps = createDependencies({ formatSettingsResponse: vi.fn(() => ({})) });
147
- deps.getOpenCodeResolutionSnapshot.mockResolvedValue({ source: 'path' });
148
- deps.buildOpenCodeUrl.mockImplementation((pathname) => `http://opencode.test${pathname}`);
149
- deps.refreshOpenCodeAfterConfigChange.mockResolvedValue(undefined);
134
+ const deps = {
135
+ ...createDependencies({ formatSettingsResponse: vi.fn(() => ({})) }),
136
+ getOpenCodeResolutionSnapshot: vi.fn(async () => ({ source: 'path' })),
137
+ buildOpenCodeUrl: vi.fn((pathname) => `http://opencode.test${pathname}`),
138
+ getOpenCodeAuthHeaders: vi.fn(() => ({ Authorization: 'Basic secret' })),
139
+ refreshOpenCodeAfterConfigChange: vi.fn(async () => undefined),
140
+ ...overrides,
141
+ };
150
142
  registerOpenCodeRoutes(app, deps);
151
143
  return { app, deps };
152
144
  };
153
145
 
154
- it('forwards an explicit target to OpenCode /global/upgrade', async () => {
155
- const fetchMock = vi.fn(async (url, init) => {
156
- expect(String(url)).toBe('http://opencode.test/global/upgrade');
157
- expect(JSON.parse(init.body)).toEqual({ target: '1.18.26' });
158
- return jsonResponse({ success: true, version: '1.18.26' });
159
- });
160
- globalThis.fetch = fetchMock;
146
+ it('rejects 1.x upgrade targets and never calls /global/upgrade', async () => {
147
+ const fetchMock = vi.fn();
148
+ vi.stubGlobal('fetch', fetchMock);
161
149
  const { app } = createUpgradeApp();
162
150
 
163
151
  const response = await request(app)
164
152
  .post('/api/opencode/upgrade')
165
- .send({ target: 'v1.18.26' })
166
- .expect(200);
153
+ .send({ target: '1.18.4' })
154
+ .expect(400);
167
155
 
168
- expect(response.body).toEqual({ success: true, version: '1.18.26', restarted: true });
169
- expect(fetchMock).toHaveBeenCalledTimes(1);
156
+ expect(response.body.success).toBe(false);
157
+ expect(response.body.error).toMatch(/1\.x/);
158
+ expect(fetchMock).not.toHaveBeenCalled();
159
+ vi.unstubAllGlobals();
170
160
  });
171
161
 
172
- it('rejects an omitted target without calling OpenCode', async () => {
173
- const fetchMock = vi.fn();
174
- globalThis.fetch = fetchMock;
162
+ it('does not treat a 1.x latest string as an available upgrade', async () => {
163
+ const fetchMock = vi.fn(async (url) => {
164
+ const href = String(url);
165
+ if (href.includes('/api/info')) {
166
+ return {
167
+ ok: true,
168
+ json: async () => ({ version: '2.0.12', healthy: true }),
169
+ };
170
+ }
171
+ if (href.includes('registry.npmjs.org/opencode-ai') || href.includes('github.com/repos/anomalyco/opencode')) {
172
+ return { ok: true, json: async () => ({ version: '1.18.18', tag_name: 'v1.18.18' }) };
173
+ }
174
+ throw new Error(`unexpected fetch ${href}`);
175
+ });
176
+ vi.stubGlobal('fetch', fetchMock);
175
177
  const { app } = createUpgradeApp();
176
178
 
177
- const response = await request(app).post('/api/opencode/upgrade').send({}).expect(400);
179
+ const response = await request(app).get('/api/opencode/upgrade-status').expect(200);
178
180
 
179
- expect(response.body).toEqual({
180
- success: false,
181
- error: 'OpenCode upgrade requires a semantic version target',
182
- });
183
- expect(fetchMock).not.toHaveBeenCalled();
181
+ expect(response.body.latestVersion).not.toMatch(/^v?1\./);
182
+ expect(isOpenCode1xVersion(response.body.latestVersion)).toBe(false);
183
+ expect(String(response.body.latestVersion || '')).not.toContain('1.18');
184
+ vi.unstubAllGlobals();
184
185
  });
185
186
 
186
- it('surfaces OpenCode schema BadRequest messages instead of statusText', async () => {
187
- const fetchMock = vi.fn(async () =>
188
- jsonResponse(
189
- { name: 'BadRequest', data: { message: 'Expected a semantic version', kind: 'Missing' } },
190
- 400,
191
- 'Bad Request',
192
- ),
193
- );
194
- globalThis.fetch = fetchMock;
195
- const { app } = createUpgradeApp();
187
+ it('returns 409 for external OpenCode and does not refresh', async () => {
188
+ const refreshOpenCodeAfterConfigChange = vi.fn(async () => undefined);
189
+ const { app, deps } = createUpgradeApp({
190
+ getIsExternalOpenCode: () => true,
191
+ refreshOpenCodeAfterConfigChange,
192
+ });
196
193
 
197
194
  const response = await request(app)
198
195
  .post('/api/opencode/upgrade')
199
- .send({ target: 'latest' })
200
- .expect(400);
196
+ .send({})
197
+ .expect(409);
201
198
 
202
- expect(response.body).toEqual({ success: false, error: 'Expected a semantic version' });
199
+ expect(response.body.success).toBe(false);
200
+ expect(response.body.error).toMatch(/your own OpenCode serve/i);
201
+ expect(deps.refreshOpenCodeAfterConfigChange).not.toHaveBeenCalled();
203
202
  });
204
203
  });