@vantran-se/openchamber-web 2.0.3 → 2.0.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (265) hide show
  1. package/README.md +20 -28
  2. package/bin/cli.js +3 -1
  3. package/bin/cli.test.js +13 -4
  4. package/bin/lib/DOCUMENTATION.md +4 -1
  5. package/bin/lib/commands-serve.js +9 -0
  6. package/dist/assets/{CodeMirrorCommentWidgets-CWhdh0wf.js → CodeMirrorCommentWidgets-DCAQTLMM.js} +1 -1
  7. package/dist/assets/DiffView-uzNpHBlV.js +4 -0
  8. package/dist/assets/FilesView-Bkq0sjNx.js +17 -0
  9. package/dist/assets/{GitView-DKIjpnu4.js → GitView-BmibZC7n.js} +4 -4
  10. package/dist/assets/{GuestAttachDialog-BIa9jCfL.js → GuestAttachDialog-D7TJG1rt.js} +1 -1
  11. package/dist/assets/GuestSurfacePane-PAcYaFzP.js +1 -0
  12. package/dist/assets/{JsonTreeView-CtI1Mqwo.js → JsonTreeView-BNT2yUVu.js} +1 -1
  13. package/dist/assets/{LinearIssuesView-BD2zLu-r.js → LinearIssuesView-D3Azksju.js} +1 -1
  14. package/dist/assets/MarkdownImageGallery-BGY6tuiK.js +1 -0
  15. package/dist/assets/MarkdownRendererImpl-DJmhQ37f.js +12 -0
  16. package/dist/assets/{OnboardingScreen-B8TxloXZ.js → OnboardingScreen-D_41H2qC.js} +1 -1
  17. package/dist/assets/{PatchDiff--Civs1xY.js → PatchDiff-C0RSqa9W.js} +1 -1
  18. package/dist/assets/{PierreDiffViewer-BlDmw3-A.js → PierreDiffViewer-BBE7tTY2.js} +3 -3
  19. package/dist/assets/{PlanView-DyH2SyCP.js → PlanView-Bqc-xuDe.js} +4 -4
  20. package/dist/assets/PluginPane-DQ0k41Q-.js +3 -0
  21. package/dist/assets/SettingsView-BAiJU5hJ.js +30 -0
  22. package/dist/assets/{SettingsWindow-y9WpZqK6.js → SettingsWindow-DuC4ecLw.js} +1 -1
  23. package/dist/assets/{SubmoduleDiffSummary-DeFmhPFZ.js → SubmoduleDiffSummary-0HgmcMXz.js} +1 -1
  24. package/dist/assets/{ToolOutputDialog-8DmF7_PZ.js → ToolOutputDialog-DdHkQJEE.js} +6 -6
  25. package/dist/assets/{ToolPartDiffPreview-Ca15DKE1.js → ToolPartDiffPreview-DWdN0r5K.js} +2 -2
  26. package/dist/assets/{VirtualizedFileDiff-BfEBad09.js → VirtualizedFileDiff-ClDezf2d.js} +1 -1
  27. package/dist/assets/{WalkthroughView-Cj3K8Xz6.js → WalkthroughView-BJicp0RC.js} +2 -2
  28. package/dist/assets/{appThemeRegistry-DAdawJZn.js → appThemeRegistry-CiuyzfSW.js} +1 -1
  29. package/dist/assets/changesTree-CePHbFcj.js +1 -0
  30. package/dist/assets/commitAndPush-DTKb6IwT.js +1 -0
  31. package/dist/assets/{computeVirtualFileMetrics-RF-KjfUH.js → computeVirtualFileMetrics-kM_zJNMt.js} +2 -2
  32. package/dist/assets/de-BMnfXm-G.js +10 -0
  33. package/dist/assets/es-DKEvCCL6.js +16 -0
  34. package/dist/assets/fr-D9fgE2xE.js +14 -0
  35. package/dist/assets/index-DUke1nme.css +1 -0
  36. package/dist/assets/{index-CL7ZQ94-.js → index-DWB8bR_g.js} +1 -1
  37. package/dist/assets/index-Di6zUe5h.js +41 -0
  38. package/dist/assets/{isDiffPlainText-D2yJ9fO6.js → isDiffPlainText-DOoIsGqs.js} +1 -1
  39. package/dist/assets/{isFilePlainText-BwMEq1Ey.js → isFilePlainText-bt5VHvcd.js} +1 -1
  40. package/dist/assets/ja-BEXad-KW.js +16 -0
  41. package/dist/assets/ko-DGrQ0lEe.js +16 -0
  42. package/dist/assets/{main-quLSA7jc.js → main-BpLQbZC2.js} +2 -2
  43. package/dist/assets/main-CI9DtHJv.js +896 -0
  44. package/dist/assets/{markdown-shiki.worker-CahLvHIq.js → markdown-shiki.worker-Bg8SoAJJ.js} +8 -5
  45. package/dist/assets/markdownCore-DK2KK5RW.js +7 -0
  46. package/dist/assets/{miniChat-D-lJJibr.js → miniChat-CIWaR8wG.js} +2 -2
  47. package/dist/assets/{mobile-VTtphbW6.js → mobile-CnWKEYIP.js} +2 -2
  48. package/dist/assets/patchFileDiff-Cqn0C0az.js +13 -0
  49. package/dist/assets/pl-BlhkmR5b.js +16 -0
  50. package/dist/assets/pt-BR-Bs96Ew9I.js +16 -0
  51. package/dist/assets/{renderElectronMiniChatApp-DF7ch5eM.js → renderElectronMiniChatApp-D9t0-9nV.js} +2 -2
  52. package/dist/assets/renderMobileApp-CW7fVs_s.js +25 -0
  53. package/dist/assets/runtimeEndpointReset-D0PcCb3K.js +27 -0
  54. package/dist/assets/scheduledTasksApi-Dahv3ws0.js +1 -0
  55. package/dist/assets/{shikiHighlight-_HeCCHx1.js → shikiHighlight-l-MbaNS4.js} +1 -1
  56. package/dist/assets/{textMateThemeFromAppTheme-C95XdFGj.js → textMateThemeFromAppTheme-C-kGcOpD.js} +1 -1
  57. package/dist/assets/tr-CehUK1M9.js +7 -0
  58. package/dist/assets/uk-Bno5WGNz.js +16 -0
  59. package/dist/assets/{useAppFontEffects-ZM9ij19-.js → useAppFontEffects-DDjuwVqi.js} +160 -156
  60. package/dist/assets/{useInlineCommentController-CkfxMJPy.js → useInlineCommentController-Clg8EO3A.js} +2 -2
  61. package/dist/assets/{useRootScrollLock-BtiAC1fj.js → useRootScrollLock-nDcRrgJ1.js} +2 -2
  62. package/dist/assets/{vendor-codemirror-language-data-U-lXzXNK.js → vendor-codemirror-language-data-oKF0MAwd.js} +2 -2
  63. package/dist/assets/vendor-opencode-client-DSZl7jSi.js +12 -0
  64. package/dist/assets/vendor-zod-BSoePkpP.js +39 -0
  65. package/dist/assets/{web-components-C583Pmfm.js → web-components-Bc1WPd4i.js} +1 -1
  66. package/dist/assets/zh-CN-DnAweIWo.js +16 -0
  67. package/dist/assets/zh-TW-BbBBK25p.js +16 -0
  68. package/dist/index.html +5 -5
  69. package/dist/mini-chat.html +5 -5
  70. package/dist/mobile.html +5 -5
  71. package/package.json +3 -3
  72. package/server/index.d.ts +2 -0
  73. package/server/index.js +196 -52
  74. package/server/lib/agent-tool/DOCUMENTATION.md +5 -0
  75. package/server/lib/agent-tool/runtime.js +89 -51
  76. package/server/lib/agent-tool/runtime.test.js +17 -0
  77. package/server/lib/browser-control/DOCUMENTATION.md +14 -2
  78. package/server/lib/event-stream/DOCUMENTATION.md +2 -2
  79. package/server/lib/event-stream/global-hub.js +45 -7
  80. package/server/lib/event-stream/global-hub.test.js +128 -2
  81. package/server/lib/event-stream/global-ws-bridge.js +3 -1
  82. package/server/lib/event-stream/rebind.test.js +1 -0
  83. package/server/lib/event-stream/runtime.js +1 -2
  84. package/server/lib/event-stream/upstream-reader.js +3 -2
  85. package/server/lib/git/DOCUMENTATION.md +1 -1
  86. package/server/lib/git/service.js +22 -0
  87. package/server/lib/git/service.test.js +8 -0
  88. package/server/lib/guests/DOCUMENTATION.md +4 -3
  89. package/server/lib/guests/catalog.js +19 -1
  90. package/server/lib/guests/catalog.test.js +22 -0
  91. package/server/lib/guests/html-styles.js +5 -3
  92. package/server/lib/guests/html-styles.test.js +3 -1
  93. package/server/lib/guests/routes.js +11 -3
  94. package/server/lib/guests/service.js +5 -0
  95. package/server/lib/guests/surface.js +55 -9
  96. package/server/lib/guests/surface.test.js +56 -6
  97. package/server/lib/notifications/DOCUMENTATION.md +10 -0
  98. package/server/lib/notifications/emit-route.js +118 -0
  99. package/server/lib/notifications/emit-route.test.js +112 -0
  100. package/server/lib/openchamber-control/actions.js +16 -2
  101. package/server/lib/openchamber-control/service.js +23 -0
  102. package/server/lib/openchamber-control/service.test.js +31 -0
  103. package/server/lib/openchamber-sessions/fork-inheritance.js +103 -0
  104. package/server/lib/openchamber-sessions/fork-inheritance.test.js +69 -0
  105. package/server/lib/openchamber-sessions/routes.js +11 -0
  106. package/server/lib/openchamber-sessions/routes.test.js +18 -0
  107. package/server/lib/opencode/DOCUMENTATION.md +23 -23
  108. package/server/lib/opencode/bootstrap-runtime.js +14 -1
  109. package/server/lib/opencode/compatibility.js +6 -3
  110. package/server/lib/opencode/compatibility.test.js +8 -0
  111. package/server/lib/opencode/config-entity-v2.test.js +27 -0
  112. package/server/lib/opencode/config-paths.test.js +12 -0
  113. package/server/lib/opencode/config-v2.d.ts +2 -0
  114. package/server/lib/opencode/config-v2.js +42 -0
  115. package/server/lib/opencode/config-v2.test.js +17 -0
  116. package/server/lib/opencode/core-routes.js +20 -4
  117. package/server/lib/opencode/core-routes.test.js +41 -0
  118. package/server/lib/opencode/env-runtime.js +1 -8
  119. package/server/lib/opencode/feature-routes-runtime.js +2 -1
  120. package/server/lib/opencode/lifecycle.js +156 -2
  121. package/server/lib/opencode/lifecycle.test.js +255 -1
  122. package/server/lib/opencode/managed-config-file.js +18 -6
  123. package/server/lib/opencode/managed-config-file.test.js +23 -7
  124. package/server/lib/opencode/mcp.js +7 -2
  125. package/server/lib/opencode/network-runtime.js +16 -7
  126. package/server/lib/opencode/network-runtime.test.js +29 -1
  127. package/server/lib/opencode/npm-registry-config.d.ts +1 -0
  128. package/server/lib/opencode/npm-registry-config.js +123 -0
  129. package/server/lib/opencode/npm-registry-config.test.js +167 -0
  130. package/server/lib/opencode/npm-registry.js +5 -6
  131. package/server/lib/opencode/npm-registry.test.js +30 -0
  132. package/server/lib/opencode/openchamber-routes.js +29 -11
  133. package/server/lib/opencode/openchamber-routes.test.js +18 -0
  134. package/server/lib/opencode/project-directory-runtime.js +8 -0
  135. package/server/lib/opencode/project-directory-runtime.test.js +13 -0
  136. package/server/lib/opencode/provider-source-route.test.js +59 -0
  137. package/server/lib/opencode/providers.js +3 -0
  138. package/server/lib/opencode/providers.test.js +62 -0
  139. package/server/lib/opencode/proxy.js +53 -16
  140. package/server/lib/opencode/routes-upgrade.test.js +1 -1
  141. package/server/lib/opencode/routes.js +36 -1
  142. package/server/lib/opencode/server-startup-runtime.js +25 -0
  143. package/server/lib/opencode/server-startup-runtime.test.js +67 -1
  144. package/server/lib/opencode/server-utils-runtime.js +5 -0
  145. package/server/lib/opencode/settings-helpers.js +10 -0
  146. package/server/lib/opencode/settings-helpers.test.js +2 -2
  147. package/server/lib/opencode/settings-registry.json +17 -0
  148. package/server/lib/opencode/settings-runtime.js +1 -0
  149. package/server/lib/opencode/shared-service-runtime.js +155 -0
  150. package/server/lib/opencode/shared-service-runtime.test.js +180 -0
  151. package/server/lib/opencode/shared.js +49 -4
  152. package/server/lib/opencode/shared.test.js +78 -1
  153. package/server/lib/opencode/shutdown-runtime.js +14 -5
  154. package/server/lib/opencode/shutdown-runtime.test.js +77 -2
  155. package/server/lib/opencode/skill-routes.js +28 -12
  156. package/server/lib/opencode/skill-routes.test.js +121 -1
  157. package/server/lib/opencode/startup-pipeline-runtime.js +5 -4
  158. package/server/lib/opencode/startup-pipeline-runtime.test.js +18 -7
  159. package/server/lib/opencode/upgrade-capability.js +0 -14
  160. package/server/lib/opencode/v1-migration-topup.js +21 -15
  161. package/server/lib/opencode/v1-migration-topup.test.js +14 -6
  162. package/server/lib/opencode/v2-install.d.ts +1 -0
  163. package/server/lib/opencode/v2-install.js +8 -7
  164. package/server/lib/opencode/v2-install.test.js +13 -4
  165. package/server/lib/opencode/watcher.js +3 -1
  166. package/server/lib/opencode/watcher.test.js +54 -0
  167. package/server/lib/opencode/websearch-config.js +13 -1
  168. package/server/lib/quota/providers/kimi.js +1 -1
  169. package/server/lib/realtime-proxy.js +5 -1
  170. package/server/lib/realtime-proxy.test.js +29 -0
  171. package/server/lib/relay/tunnel-host.js +4 -1
  172. package/server/lib/relay/tunnel-host.test.js +11 -0
  173. package/server/lib/session-assist/DOCUMENTATION.md +17 -5
  174. package/server/lib/session-assist/runtime.js +19 -2
  175. package/server/lib/session-assist/runtime.test.js +8 -0
  176. package/server/lib/session-goal/DOCUMENTATION.md +11 -0
  177. package/server/lib/small-model/DOCUMENTATION.md +11 -10
  178. package/server/lib/small-model/index.js +27 -32
  179. package/server/lib/small-model/index.test.js +28 -16
  180. package/server/lib/spaces/DOCUMENTATION.md +404 -32
  181. package/server/lib/spaces/code-in-bait.js +145 -6
  182. package/server/lib/spaces/code-in.js +10 -150
  183. package/server/lib/spaces/code-in.test.js +7 -83
  184. package/server/lib/spaces/code-out.js +1819 -0
  185. package/server/lib/spaces/code-out.test.js +3945 -0
  186. package/server/lib/spaces/code-transfer.js +173 -0
  187. package/server/lib/spaces/command-stream.test.js +181 -0
  188. package/server/lib/spaces/dispatcher.js +670 -0
  189. package/server/lib/spaces/dispatcher.test.js +582 -0
  190. package/server/lib/spaces/gatekeeper-channel.js +61 -7
  191. package/server/lib/spaces/gatekeeper-channel.test.js +43 -7
  192. package/server/lib/spaces/gatekeeper-program.cjs +68 -14
  193. package/server/lib/spaces/gatekeeper-program.test.js +118 -3
  194. package/server/lib/spaces/hardening.js +20 -4
  195. package/server/lib/spaces/hardening.test.js +18 -3
  196. package/server/lib/spaces/host-git.js +7 -4
  197. package/server/lib/spaces/host-git.test.js +6 -2
  198. package/server/lib/spaces/host.js +310 -0
  199. package/server/lib/spaces/host.test.js +273 -0
  200. package/server/lib/spaces/journey.js +552 -0
  201. package/server/lib/spaces/journey.test.js +589 -0
  202. package/server/lib/spaces/layout.js +42 -8
  203. package/server/lib/spaces/manager.js +4 -3
  204. package/server/lib/spaces/manager.test.js +6 -0
  205. package/server/lib/spaces/places/code-out.docker.live.test.js +412 -0
  206. package/server/lib/spaces/places/contract-suite.js +36 -0
  207. package/server/lib/spaces/places/dispatcher.docker.live.test.js +296 -0
  208. package/server/lib/spaces/places/docker-live-support.js +27 -4
  209. package/server/lib/spaces/places/docker.js +33 -5
  210. package/server/lib/spaces/places/docker.test.js +42 -3
  211. package/server/lib/spaces/places/escape-suite.js +118 -14
  212. package/server/lib/spaces/places/fake-docker.js +3 -1
  213. package/server/lib/spaces/places/grants.docker.live.test.js +197 -0
  214. package/server/lib/spaces/places/memory-place.js +24 -1
  215. package/server/lib/spaces/places/registry.js +1 -1
  216. package/server/lib/spaces/places/server.docker.live.test.js +69 -33
  217. package/server/lib/spaces/places/sessions.docker.live.test.js +222 -0
  218. package/server/lib/spaces/routes.js +216 -0
  219. package/server/lib/spaces/routes.test.js +213 -0
  220. package/server/lib/spaces/run-command.js +261 -8
  221. package/server/lib/spaces/run-command.test.js +193 -3
  222. package/server/lib/spaces/space-events.js +153 -0
  223. package/server/lib/spaces/space-events.test.js +160 -0
  224. package/server/lib/spaces/space-opencode.js +52 -0
  225. package/server/lib/spaces/space-opencode.test.js +41 -0
  226. package/server/lib/spaces/space-records.js +150 -0
  227. package/server/lib/spaces/space-records.test.js +113 -0
  228. package/server/lib/spaces/space-sessions.js +210 -0
  229. package/server/lib/spaces/space-sessions.test.js +161 -0
  230. package/server/lib/spaces/websocket.js +329 -0
  231. package/server/lib/spaces/websocket.test.js +345 -0
  232. package/server/lib/ui-auth/ui-auth.js +8 -2
  233. package/server/lib/ui-auth/ui-auth.test.js +29 -0
  234. package/server/opencode-proxy.test.js +271 -0
  235. package/server/proxy-headers.js +8 -2
  236. package/server/proxy-headers.test.js +20 -0
  237. package/dist/assets/DiffView-CX3wAX2S.js +0 -4
  238. package/dist/assets/FilesView-OL3CON3r.js +0 -17
  239. package/dist/assets/GuestSurfacePane-B_cqX5I5.js +0 -1
  240. package/dist/assets/MarkdownImageGallery-DFiTFZgj.js +0 -1
  241. package/dist/assets/MarkdownRendererImpl-DZm5sf1W.js +0 -12
  242. package/dist/assets/PluginPane-BibJit86.js +0 -3
  243. package/dist/assets/SettingsView-h-VpVO47.js +0 -29
  244. package/dist/assets/commitAndPush-BIPfOMXY.js +0 -1
  245. package/dist/assets/de-BqCNGROu.js +0 -10
  246. package/dist/assets/es-BTPpTRgN.js +0 -16
  247. package/dist/assets/fr-hxbzMdAg.js +0 -14
  248. package/dist/assets/index-CLcM8HKx.css +0 -1
  249. package/dist/assets/index-CvLgnuNI.js +0 -41
  250. package/dist/assets/ja-_zLwwYiy.js +0 -16
  251. package/dist/assets/ko-CFn3Dzhc.js +0 -16
  252. package/dist/assets/main-BLJaug9I.js +0 -896
  253. package/dist/assets/markdownCore-DS9FdwrI.js +0 -5
  254. package/dist/assets/patchFileDiff-D9iBcTEi.js +0 -13
  255. package/dist/assets/pl-DFHE7xac.js +0 -16
  256. package/dist/assets/pt-BR-CUjZF179.js +0 -16
  257. package/dist/assets/renderMobileApp-CNQhdXsi.js +0 -25
  258. package/dist/assets/runtimeEndpointReset-DeaERc0M.js +0 -27
  259. package/dist/assets/scheduledTasksApi-BD6Vx9pu.js +0 -1
  260. package/dist/assets/tr-BB6sA_zH.js +0 -7
  261. package/dist/assets/uk-CB4o8g-o.js +0 -16
  262. package/dist/assets/vendor-opencode-client-_BAHQo4R.js +0 -12
  263. package/dist/assets/vendor-zod-Gea9uBlY.js +0 -39
  264. package/dist/assets/zh-CN-BRyDuWhc.js +0 -16
  265. package/dist/assets/zh-TW-BDGbWA1Q.js +0 -16
@@ -16,18 +16,20 @@ const createRuntime = (server, overrides = {}) => {
16
16
  openCodeWatcherRuntime: { stop: vi.fn() },
17
17
  sessionRuntime: { dispose: vi.fn() },
18
18
  scheduledTasksRuntime: { stop: vi.fn() },
19
+ globalEventHub: { stop: vi.fn() },
19
20
  getHealthCheckInterval: () => null,
20
21
  clearHealthCheckInterval: vi.fn(),
21
22
  getTerminalRuntime: () => null,
22
23
  setTerminalRuntime: vi.fn(),
23
24
  getMessageStreamRuntime: () => null,
24
25
  setMessageStreamRuntime: vi.fn(),
25
- shouldSkipOpenCodeStop: () => true,
26
+ getOpenCodeConnectionKind: () => 'explicit-external',
26
27
  getOpenCodePort: () => null,
27
28
  getOpenCodeProcess: () => null,
28
29
  setOpenCodeProcess: vi.fn(),
29
30
  killProcessOnPort: vi.fn(),
30
31
  waitForPortRelease: vi.fn(async () => true),
32
+ disposeSharedOpenCodeService: vi.fn(async () => {}),
31
33
  getServer: () => server,
32
34
  getUiAuthController: () => null,
33
35
  setUiAuthController: vi.fn(),
@@ -151,7 +153,7 @@ describe('graceful shutdown runtime', () => {
151
153
  throw new Error('fixture cleanup failure');
152
154
  },
153
155
  getTerminalRuntime: () => ({ shutdown: terminalShutdown }),
154
- shouldSkipOpenCodeStop: () => false,
156
+ getOpenCodeConnectionKind: () => 'managed-owned',
155
157
  getOpenCodeProcess: () => ({ close: processClose }),
156
158
  });
157
159
  await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
@@ -187,6 +189,70 @@ describe('graceful shutdown runtime', () => {
187
189
  }
188
190
  });
189
191
 
192
+ it('only managed-owned closes and kills OpenCode before waiting for its port', async () => {
193
+ const close = vi.fn(async () => {});
194
+ const killProcessOnPort = vi.fn();
195
+ const waitForPortRelease = vi.fn(async () => true);
196
+ const disposeSharedOpenCodeService = vi.fn(async () => {});
197
+ const runtime = createRuntime(null, {
198
+ getOpenCodeConnectionKind: () => 'managed-owned',
199
+ getOpenCodePort: () => 4096,
200
+ getOpenCodeProcess: () => ({ close }),
201
+ killProcessOnPort,
202
+ waitForPortRelease,
203
+ disposeSharedOpenCodeService,
204
+ });
205
+
206
+ await runtime.gracefulShutdown({ exitProcess: false });
207
+
208
+ expect(close).toHaveBeenCalledOnce();
209
+ expect(killProcessOnPort).toHaveBeenCalledWith(4096);
210
+ expect(waitForPortRelease).toHaveBeenCalledWith(4096, 5000);
211
+ expect(disposeSharedOpenCodeService).not.toHaveBeenCalled();
212
+ });
213
+
214
+ it('shared-local disposes connection state without touching the shared service process', async () => {
215
+ const close = vi.fn();
216
+ const killProcessOnPort = vi.fn();
217
+ const waitForPortRelease = vi.fn();
218
+ const disposeSharedOpenCodeService = vi.fn(async () => {});
219
+ const runtime = createRuntime(null, {
220
+ getOpenCodeConnectionKind: () => 'shared-local',
221
+ getOpenCodeProcess: () => ({ close }),
222
+ killProcessOnPort,
223
+ waitForPortRelease,
224
+ disposeSharedOpenCodeService,
225
+ });
226
+
227
+ await runtime.gracefulShutdown({ exitProcess: false });
228
+
229
+ expect(disposeSharedOpenCodeService).toHaveBeenCalledOnce();
230
+ expect(close).not.toHaveBeenCalled();
231
+ expect(killProcessOnPort).not.toHaveBeenCalled();
232
+ expect(waitForPortRelease).not.toHaveBeenCalled();
233
+ });
234
+
235
+ it('explicit-external leaves OpenCode and shared state untouched', async () => {
236
+ const close = vi.fn();
237
+ const killProcessOnPort = vi.fn();
238
+ const waitForPortRelease = vi.fn();
239
+ const disposeSharedOpenCodeService = vi.fn();
240
+ const runtime = createRuntime(null, {
241
+ getOpenCodeConnectionKind: () => 'explicit-external',
242
+ getOpenCodeProcess: () => ({ close }),
243
+ killProcessOnPort,
244
+ waitForPortRelease,
245
+ disposeSharedOpenCodeService,
246
+ });
247
+
248
+ await runtime.gracefulShutdown({ exitProcess: false });
249
+
250
+ expect(close).not.toHaveBeenCalled();
251
+ expect(killProcessOnPort).not.toHaveBeenCalled();
252
+ expect(waitForPortRelease).not.toHaveBeenCalled();
253
+ expect(disposeSharedOpenCodeService).not.toHaveBeenCalled();
254
+ });
255
+
190
256
  it('stops guest services during shutdown', async () => {
191
257
  const server = {
192
258
  close: vi.fn((callback) => {
@@ -255,6 +321,15 @@ describe('graceful shutdown runtime', () => {
255
321
  expect(vi.getTimerCount()).toBe(0);
256
322
  });
257
323
 
324
+ it('stops the application-owned global event hub during embedded shutdown', async () => {
325
+ const globalEventHub = { stop: vi.fn() };
326
+ const runtime = createRuntime(null, { globalEventHub });
327
+
328
+ await runtime.gracefulShutdown({ exitProcess: false });
329
+
330
+ expect(globalEventHub.stop).toHaveBeenCalledOnce();
331
+ });
332
+
258
333
  it('isolates failed viewer and relay cleanup and still drains guests and exits', async () => {
259
334
  const stop = vi.fn(() => { throw new Error('stop failed'); });
260
335
  const stopAllGuestServices = vi.fn(async () => {});
@@ -125,9 +125,12 @@ export const registerSkillRoutes = (app, dependencies) => {
125
125
  return { scope: SKILL_SCOPE.USER, source };
126
126
  };
127
127
 
128
+ // Returns null when OpenCode's list could not be read (not running, error,
129
+ // timeout, malformed payload) so callers can tell a failed fetch from a
130
+ // genuinely empty list.
128
131
  const fetchOpenCodeDiscoveredSkills = async (workingDirectory) => {
129
132
  if (!getOpenCodePort()) {
130
- return [];
133
+ return null;
131
134
  }
132
135
 
133
136
  try {
@@ -145,13 +148,19 @@ export const registerSkillRoutes = (app, dependencies) => {
145
148
  const response = await client.skill.list();
146
149
  const payload = response?.data;
147
150
  if (!Array.isArray(payload)) {
148
- return [];
151
+ return null;
149
152
  }
150
153
 
151
154
  return payload
152
155
  .map((item) => {
153
156
  const name = typeof item?.name === 'string' ? item.name.trim() : '';
154
- const location = typeof item?.location === 'string' ? item.location : '';
157
+ // OpenCode v1's skill payload used `location`; v2 renamed the field
158
+ // to `path`. Accept both, or the whole authoritative list is dropped
159
+ // and the panel falls back to the (smaller) local disk scan.
160
+ const rawLocation = typeof item?.path === 'string' ? item.path : (typeof item?.location === 'string' ? item.location : '');
161
+ // v1 marked built-in skills with `<built-in>`; v2 gives them a synthetic
162
+ // `/builtin/<id>.md` path. Normalize so they stay read-only in the panel.
163
+ const location = rawLocation.startsWith('/builtin/') ? '<built-in>' : rawLocation;
155
164
  const description = typeof item?.description === 'string' ? item.description : '';
156
165
  const content = typeof item?.content === 'string' ? item.content : '';
157
166
  if (!name || !location) {
@@ -183,7 +192,7 @@ export const registerSkillRoutes = (app, dependencies) => {
183
192
  .filter(Boolean);
184
193
  } catch (error) {
185
194
  console.error('Failed to list OpenCode skills:', error);
186
- return [];
195
+ return null;
187
196
  }
188
197
  };
189
198
 
@@ -244,7 +253,7 @@ export const registerSkillRoutes = (app, dependencies) => {
244
253
  }
245
254
  const openCodeSkills = await fetchOpenCodeDiscoveredSkills(directory);
246
255
  const localSkills = discoverSkills(directory);
247
- const skills = mergeDiscoveredSkills(openCodeSkills, localSkills);
256
+ const skills = mergeDiscoveredSkills(openCodeSkills ?? [], localSkills);
248
257
 
249
258
  const enrichedSkills = skills.map((skill) => {
250
259
  const sources = getSkillSources(skill.name, directory, skill);
@@ -268,7 +277,7 @@ export const registerSkillRoutes = (app, dependencies) => {
268
277
  // OpenCode's own skill-list endpoint is not usable for this: on 1.18.14
269
278
  // it returns only global and builtin skills, omitting the project
270
279
  // `.agents`/`.claude` skills the agent demonstrably has.
271
- res.json({
280
+ const body = {
272
281
  skills: enrichedSkills,
273
282
  externalSkills: {
274
283
  // `OPENCODE_DISABLE_CLAUDE_CODE` is the broad switch; the specific
@@ -277,7 +286,14 @@ export const registerSkillRoutes = (app, dependencies) => {
277
286
  || isEnvFlagEnabled(process.env.OPENCODE_DISABLE_CLAUDE_CODE_SKILLS),
278
287
  allDisabled: isEnvFlagEnabled(process.env.OPENCODE_DISABLE_EXTERNAL_SKILLS),
279
288
  },
280
- });
289
+ };
290
+ // The list is only the disk scan when OpenCode's own list failed:
291
+ // built-in skills and anything OpenCode finds only through its config
292
+ // are missing, so the client must not treat this as complete.
293
+ if (openCodeSkills === null) {
294
+ body.openCodeSkillsUnavailable = true;
295
+ }
296
+ res.json(body);
281
297
  } catch (error) {
282
298
  console.error('Failed to list skills:', error);
283
299
  res.status(500).json({ error: 'Failed to list skills' });
@@ -366,7 +382,7 @@ export const registerSkillRoutes = (app, dependencies) => {
366
382
  }
367
383
 
368
384
  const resolvedDiscovered = mergeDiscoveredSkills(
369
- await fetchOpenCodeDiscoveredSkills(directory),
385
+ (await fetchOpenCodeDiscoveredSkills(directory)) ?? [],
370
386
  discoverSkills(directory),
371
387
  );
372
388
  const installedByName = new Map(resolvedDiscovered.map((s) => [s.name, s]));
@@ -538,7 +554,7 @@ export const registerSkillRoutes = (app, dependencies) => {
538
554
  if (error) {
539
555
  return res.status(400).json({ error });
540
556
  }
541
- const discoveredSkill = (await fetchOpenCodeDiscoveredSkills(directory))
557
+ const discoveredSkill = ((await fetchOpenCodeDiscoveredSkills(directory)) ?? [])
542
558
  .find((skill) => skill.name === skillName) || null;
543
559
  const sources = getSkillSources(skillName, directory, discoveredSkill);
544
560
 
@@ -567,7 +583,7 @@ export const registerSkillRoutes = (app, dependencies) => {
567
583
  return res.status(400).json({ error });
568
584
  }
569
585
 
570
- const discoveredSkill = (await fetchOpenCodeDiscoveredSkills(directory))
586
+ const discoveredSkill = ((await fetchOpenCodeDiscoveredSkills(directory)) ?? [])
571
587
  .find((skill) => skill.name === skillName) || null;
572
588
  const sources = getSkillSources(skillName, directory, discoveredSkill);
573
589
  if (!sources.md.exists || !sources.md.dir) {
@@ -661,7 +677,7 @@ export const registerSkillRoutes = (app, dependencies) => {
661
677
  return res.status(400).json({ error });
662
678
  }
663
679
 
664
- const discoveredSkill = (await fetchOpenCodeDiscoveredSkills(directory))
680
+ const discoveredSkill = ((await fetchOpenCodeDiscoveredSkills(directory)) ?? [])
665
681
  .find((skill) => skill.name === skillName) || null;
666
682
  const sources = getSkillSources(skillName, directory, discoveredSkill);
667
683
  if (!sources.md.exists || !sources.md.dir) {
@@ -695,7 +711,7 @@ export const registerSkillRoutes = (app, dependencies) => {
695
711
  return res.status(400).json({ error });
696
712
  }
697
713
 
698
- const discoveredSkill = (await fetchOpenCodeDiscoveredSkills(directory))
714
+ const discoveredSkill = ((await fetchOpenCodeDiscoveredSkills(directory)) ?? [])
699
715
  .find((skill) => skill.name === skillName) || null;
700
716
  const sources = getSkillSources(skillName, directory, discoveredSkill);
701
717
  if (!sources.md.exists || !sources.md.dir) {
@@ -28,7 +28,7 @@ const createTempProject = () => {
28
28
  return projectRoot;
29
29
  };
30
30
 
31
- const startSkillsApp = ({ projectRoot }) => {
31
+ const startSkillsApp = ({ projectRoot, overrides = {} }) => {
32
32
  const app = express();
33
33
  app.use(express.json());
34
34
 
@@ -76,6 +76,7 @@ const startSkillsApp = ({ projectRoot }) => {
76
76
  fetchGitHubRepoMetas: async () => ({}),
77
77
  getProfiles: () => [],
78
78
  getProfile: () => null,
79
+ ...overrides,
79
80
  });
80
81
 
81
82
  const server = app.listen(0);
@@ -213,4 +214,123 @@ describe('skill-routes directory soft fallback', () => {
213
214
  });
214
215
  }
215
216
  });
217
+
218
+ it('lists OpenCode skills reported with the v2 `path` field and keeps the v1 `location` fallback', async () => {
219
+ projectRoot = createTempProject();
220
+
221
+ // OpenCode v2's skill payload renamed `location` to `path`. The route maps
222
+ // the fetch result to { name, path, ... }; with the old field only, the
223
+ // whole authoritative list was dropped and the panel fell back to the
224
+ // smaller local disk scan. Serve a v2-style payload from a stub.
225
+ const stub = express();
226
+ stub.get('/api/skill', (_req, res) => {
227
+ res.json({
228
+ location: { directory: projectRoot },
229
+ data: [
230
+ {
231
+ id: 'v2-path-skill',
232
+ name: 'v2-path-skill',
233
+ path: path.join(projectRoot, '.agents', 'skills', 'v2-path-skill'),
234
+ description: 'Delivered with the v2 path field',
235
+ },
236
+ {
237
+ id: 'v1-location-skill',
238
+ name: 'v1-location-skill',
239
+ location: path.join(projectRoot, '.agents', 'skills', 'v1-location-skill'),
240
+ description: 'Delivered with the legacy location field',
241
+ },
242
+ {
243
+ id: 'opencode',
244
+ name: 'OpenCode',
245
+ path: '/builtin/opencode.md',
246
+ description: 'v2 built-in skill with a synthetic path',
247
+ },
248
+ {
249
+ id: 'unlocated-skill',
250
+ name: 'unlocated-skill',
251
+ description: 'Has neither field; must be dropped',
252
+ },
253
+ ],
254
+ });
255
+ });
256
+ const stubServer = await new Promise((resolve) => {
257
+ const server = stub.listen(0, () => resolve(server));
258
+ });
259
+ const stubPort = stubServer.address().port;
260
+
261
+ try {
262
+ appHandle = startSkillsApp({
263
+ projectRoot,
264
+ overrides: {
265
+ buildOpenCodeUrl: () => `http://127.0.0.1:${stubPort}/`,
266
+ getOpenCodePort: () => stubPort,
267
+ getOpenCodeAuthHeaders: () => ({}),
268
+ },
269
+ });
270
+
271
+ const listResponse = await fetch(
272
+ `${appHandle.baseUrl}/api/config/skills?directory=${encodeURIComponent(projectRoot)}`,
273
+ );
274
+ expect(listResponse.status).toBe(200);
275
+ const payload = await listResponse.json();
276
+ const byName = new Map(payload.skills.map((skill) => [skill.name, skill]));
277
+
278
+ expect(byName.has('v2-path-skill')).toBe(true);
279
+ expect(byName.get('v2-path-skill').path).toContain('v2-path-skill');
280
+
281
+ expect(byName.has('v1-location-skill')).toBe(true);
282
+ expect(byName.get('v1-location-skill').path).toContain('v1-location-skill');
283
+
284
+ expect(byName.get('OpenCode')?.path).toBe('<built-in>');
285
+ expect(byName.get('OpenCode')?.renamable).toBe(false);
286
+
287
+ expect(byName.has('unlocated-skill')).toBe(false);
288
+ } finally {
289
+ stubServer.close();
290
+ }
291
+ });
292
+ it('flags the list as partial when OpenCode skill list fails, and not when it succeeds', async () => {
293
+ projectRoot = createTempProject();
294
+ fs.mkdirSync(path.join(projectRoot, '.agents', 'skills', 'disk-skill'), { recursive: true });
295
+ fs.writeFileSync(
296
+ path.join(projectRoot, '.agents', 'skills', 'disk-skill', 'SKILL.md'),
297
+ '---\nname: disk-skill\ndescription: On disk\n---\nBody\n',
298
+ );
299
+
300
+ let failing = true;
301
+ const stub = express();
302
+ stub.get('/api/skill', (_req, res) => {
303
+ if (failing) {
304
+ res.status(500).json({ error: 'boom' });
305
+ return;
306
+ }
307
+ res.json({ data: [] });
308
+ });
309
+ const stubServer = await new Promise((resolve) => {
310
+ const server = stub.listen(0, () => resolve(server));
311
+ });
312
+ const stubPort = stubServer.address().port;
313
+
314
+ try {
315
+ appHandle = startSkillsApp({
316
+ projectRoot,
317
+ overrides: {
318
+ buildOpenCodeUrl: () => `http://127.0.0.1:${stubPort}/`,
319
+ getOpenCodePort: () => stubPort,
320
+ },
321
+ });
322
+ const url = `${appHandle.baseUrl}/api/config/skills?directory=${encodeURIComponent(projectRoot)}`;
323
+
324
+ const failed = await (await fetch(url)).json();
325
+ expect(failed.openCodeSkillsUnavailable).toBe(true);
326
+ expect(failed.skills.map((skill) => skill.name)).toContain('disk-skill');
327
+
328
+ failing = false;
329
+ const complete = await (await fetch(url)).json();
330
+ expect(complete.openCodeSkillsUnavailable).toBeUndefined();
331
+ expect(complete.skills.map((skill) => skill.name)).toContain('disk-skill');
332
+ } finally {
333
+ stubServer.close();
334
+ }
335
+ });
216
336
  });
@@ -34,7 +34,6 @@ export const createStartupPipelineRuntime = (dependencies) => {
34
34
  terminalRebindWindowMs,
35
35
  terminalMaxRebindsPerWindow,
36
36
  setupProxy,
37
- scheduleOpenCodeApiDetection,
38
37
  bootstrapOpenCodeAtStartup,
39
38
  staticRoutesRuntime,
40
39
  process,
@@ -132,13 +131,15 @@ export const createStartupPipelineRuntime = (dependencies) => {
132
131
  bindHost,
133
132
  startupTunnelRequest,
134
133
  onTunnelReady,
134
+ afterListening: async ({ activePort }) => {
135
+ tunnelRuntimeContext.setActivePort(activePort);
136
+ await bootstrapOpenCodeAtStartup();
137
+ },
135
138
  });
136
139
  recordStartupPerformance('web.listener.ready', {
137
140
  durationMs: performance.now() - pipelineStartedAt,
138
141
  });
139
- tunnelRuntimeContext.setActivePort(startupResult.activePort);
140
- scheduleOpenCodeApiDetection();
141
- void bootstrapOpenCodeAtStartup();
142
+
142
143
 
143
144
  serverStartupRuntime.attachProcessHandlers({ attachSignals });
144
145
 
@@ -3,23 +3,27 @@ import { describe, expect, it, vi } from 'vitest';
3
3
  import { createStartupPipelineRuntime } from './startup-pipeline-runtime.js';
4
4
 
5
5
  describe('startup pipeline runtime', () => {
6
- it('publishes the listening port before bootstrapping managed OpenCode', async () => {
6
+ it('publishes the listening port before awaiting OpenCode bootstrap', async () => {
7
7
  const order = [];
8
+ let releaseBootstrap;
9
+ const bootstrapGate = new Promise((resolve) => { releaseBootstrap = resolve; });
8
10
  const runtime = createStartupPipelineRuntime({
9
11
  createTerminalRuntime: () => ({}),
10
12
  createDictationRuntime: () => ({}),
11
13
  createMessageStreamWsRuntime: () => ({}),
12
14
  createServerStartupRuntime: () => ({
13
15
  resolveBindHost: () => '127.0.0.1',
14
- startListeningAndMaybeTunnel: async () => {
16
+ startListeningAndMaybeTunnel: async ({ afterListening }) => {
15
17
  order.push('listen');
18
+ await afterListening({ activePort: 3901, bindHost: '127.0.0.1' });
19
+ order.push('ready');
16
20
  return { activePort: 3901 };
17
21
  },
18
22
  attachProcessHandlers: vi.fn(),
19
23
  }),
20
24
  });
21
25
 
22
- await runtime.run({
26
+ const run = runtime.run({
23
27
  app: {},
24
28
  setupProxy: vi.fn(),
25
29
  staticRoutesRuntime: { registerStaticRoutes: vi.fn() },
@@ -27,14 +31,21 @@ describe('startup pipeline runtime', () => {
27
31
  tunnelRuntimeContext: {
28
32
  setActivePort: (port) => order.push(`port:${port}`),
29
33
  },
30
- scheduleOpenCodeApiDetection: () => order.push('detect'),
31
- bootstrapOpenCodeAtStartup: () => order.push('bootstrap'),
34
+ bootstrapOpenCodeAtStartup: async () => {
35
+ order.push('bootstrap');
36
+ await bootstrapGate;
37
+ },
32
38
  process: {},
33
39
  crypto: {},
34
- server: {},
40
+ server: { close: (callback) => callback() },
35
41
  attachSignals: false,
36
42
  });
37
43
 
38
- expect(order).toEqual(['listen', 'port:3901', 'detect', 'bootstrap']);
44
+ await Promise.resolve();
45
+ expect(order).toEqual(['listen', 'port:3901', 'bootstrap']);
46
+ releaseBootstrap();
47
+ await run;
48
+
49
+ expect(order).toEqual(['listen', 'port:3901', 'bootstrap', 'ready']);
39
50
  });
40
51
  });
@@ -1,23 +1,9 @@
1
- // TEMPORARY WORKAROUND — Windows ARM64: native opencode.exe fails with a Bun
2
- // FFI/TinyCC dlopen error (https://github.com/anomalyco/opencode/issues/19130).
3
- // Disable OpenCode self-upgrade on ARM64 so it can't overwrite the working x64
4
- // binary with the broken ARM64 build. Remove when the upstream issue is resolved.
5
- const isWindowsArm64 = () => process.platform === 'win32' && process.arch === 'arm64';
6
-
7
1
  export const resolveOpenCodeUpgradeCapability = ({
8
2
  isExternal,
9
3
  hasManagedProcess,
10
4
  activeBinary,
11
5
  isBundledBinary,
12
6
  }) => {
13
- if (isWindowsArm64()) {
14
- return {
15
- supported: false,
16
- manager: 'openchamber',
17
- reason: 'windows-arm64-workaround',
18
- };
19
- }
20
-
21
7
  if (isExternal) {
22
8
  return {
23
9
  supported: false,
@@ -21,14 +21,16 @@
21
21
  * seen v2 activity. Every visited session gets its `session_message` rows
22
22
  * deleted and replaced by the V1 transform, so a migrated session that was
23
23
  * continued in v2 would lose that conversation.
24
- * 3. A session deleted in v2 comes back. A v2 delete removes the `session_v2`
25
- * row but leaves the legacy `session` row behind, so the loop imports it
26
- * again. Decided by the maintainer (2026-09-22): people run 1.x and 2.x
27
- * side by side while switching, and OpenChamber's own time-based cleanup
28
- * may have deleted sessions the user never touched, so importing again is
29
- * better than importing nothing. Deleting again is cheap; a lost 1.x
30
- * session is not. This holds until OpenCode ships an import route that
31
- * takes an explicit list of sessions.
24
+ * 3. Only 1.x activity since the last completed import triggers a new one.
25
+ * A v2 delete removes the `session_v2` row but leaves the legacy `session`
26
+ * row behind, so "missing from v2" alone would bring deleted sessions back
27
+ * on every start. A legacy session counts as missing only when its
28
+ * `time_updated` is newer than the completion stamp OpenCode writes on
29
+ * `migration.v1-v2`: only OpenCode 1.x writes that table, so an unchanged
30
+ * table means nobody ran 1.x since and the top-up skips. Decided by the
31
+ * maintainer (2026-09-24). When 1.x was used again, OpenCode still walks
32
+ * every id below the cursor, so a deleted session sorting below a fresh one
33
+ * comes back with it; that needs an import route taking an explicit list.
32
34
  *
33
35
  * The loop OpenCode runs is `SELECT id FROM session WHERE id < cursor ORDER BY
34
36
  * id DESC LIMIT 1`, one session at a time, so the cursor has to be strictly
@@ -196,11 +198,15 @@ const findRevisitedSessionsWithV2Activity = (db, cursor, completedAt) =>
196
198
  return parsed.success ? [parsed.data.id] : [];
197
199
  });
198
200
 
199
- /** A legacy session with no `session_v2` twin, whether never imported or deleted in v2 (see rule 3). */
200
- const MISSING_CONDITION = 'id NOT IN (SELECT id FROM session_v2)';
201
+ /**
202
+ * A legacy session with no `session_v2` twin that 1.x touched after the last
203
+ * completed import (see rule 3). Bound parameter: the completion timestamp.
204
+ */
205
+ const MISSING_CONDITION = 'id NOT IN (SELECT id FROM session_v2) AND time_updated > ?';
201
206
 
202
- const countMissingSessions = (db) =>
203
- firstRow(countRowSchema, db.all(`SELECT COUNT(*) AS value FROM session WHERE ${MISSING_CONDITION}`))?.value ?? 0;
207
+ const countMissingSessions = (db, completedAt) =>
208
+ firstRow(countRowSchema, db.all(`SELECT COUNT(*) AS value FROM session WHERE ${MISSING_CONDITION}`, [completedAt]))
209
+ ?.value ?? 0;
204
210
 
205
211
  const countRevisitedSessions = (db, cursor) =>
206
212
  firstRow(
@@ -244,12 +250,12 @@ export const topUpV1Migration = (options = {}) => {
244
250
  const migration = readCompletedMigration(db);
245
251
  if (!migration) return outcome('skipped', 'migration-not-completed');
246
252
 
247
- const missingCount = countMissingSessions(db);
253
+ const missingCount = countMissingSessions(db, migration.completedAt);
248
254
  if (missingCount === 0) return outcome('skipped', 'nothing-missing');
249
255
 
250
256
  const maxMissing = firstRow(
251
257
  nullableIdRowSchema,
252
- db.all(`SELECT MAX(id) AS id FROM session WHERE ${MISSING_CONDITION}`),
258
+ db.all(`SELECT MAX(id) AS id FROM session WHERE ${MISSING_CONDITION}`, [migration.completedAt]),
253
259
  )?.id;
254
260
  if (!maxMissing) return outcome('skipped', 'nothing-missing');
255
261
 
@@ -271,7 +277,7 @@ export const topUpV1Migration = (options = {}) => {
271
277
  MIGRATION_STATE_KEY,
272
278
  ]);
273
279
  logger.log(
274
- `[OpenCode] Scheduled the import of ${missingCount} OpenCode 1.x session(s) created after the v2 migration; ` +
280
+ `[OpenCode] Scheduled the import of ${missingCount} OpenCode 1.x session(s) changed since the last v2 import; ` +
275
281
  `OpenCode will re-import ${revisitedCount} untouched session(s) on the way.`,
276
282
  );
277
283
  return outcome('scheduled', undefined, missingCount, revisitedCount);
@@ -226,13 +226,21 @@ describe.skipIf(!sqlite)('topUpV1Migration', () => {
226
226
  });
227
227
  });
228
228
 
229
- it('imports a session deleted in v2 again rather than leaving newer 1.x sessions behind', () => {
230
- // ses_a was walked by the migration and deleted in v2 (only the legacy row
231
- // is left); ses_d was created after. The maintainer chose to bring ses_a
232
- // back over losing ses_d: deleting again is cheap, a lost session is not.
229
+ it('does not bring back a session deleted in v2 when 1.x was not used since', () => {
230
+ // ses_a was imported and then deleted in v2: only its legacy row is left,
231
+ // untouched since the last completed import.
233
232
  createDatabase();
234
- seed({ v1: [{ id: 'ses_a', timeCreated: COMPLETED_AT - 1 }, 'ses_d'] });
235
- expect(run()).toEqual({ status: 'scheduled', missing: 2, revisited: 0 });
233
+ seed({ v1: [{ id: 'ses_a', timeCreated: COMPLETED_AT - 1 }] });
234
+ expect(run()).toEqual({ status: 'skipped', missing: 0, revisited: 0, reason: 'nothing-missing' });
235
+ expect(readMigrationRow().state).toEqual({ phase: 'completed' });
236
+ });
237
+
238
+ it('imports only sessions 1.x changed since the last import, cursor included', () => {
239
+ // ses_z (deleted in v2, old) sorts above ses_d (fresh from 1.x): the
240
+ // cursor must stop at ses_d so ses_z stays gone.
241
+ createDatabase();
242
+ seed({ v1: [{ id: 'ses_z', timeCreated: COMPLETED_AT - 1 }, 'ses_d'] });
243
+ expect(run()).toEqual({ status: 'scheduled', missing: 1, revisited: 0 });
236
244
  expect(readMigrationRow().state).toEqual({ phase: 'sessions', cursor: computeResumeCursor('ses_d') });
237
245
  });
238
246
 
@@ -4,5 +4,6 @@ export function installOpenCodeV2(options?: {
4
4
  env?: NodeJS.ProcessEnv;
5
5
  fetchImpl?: typeof fetch;
6
6
  platform?: NodeJS.Platform;
7
+ arch?: NodeJS.Architecture;
7
8
  tarCommand?: string;
8
9
  }): Promise<string>;
@@ -18,10 +18,10 @@ const packageDistSchema = z.object({
18
18
  });
19
19
 
20
20
  // OpenCode's installer is a bash script, so Windows installs the npm platform
21
- // package that script downloads. x64-baseline serves arm64 too: the native arm64
22
- // build fails to load (https://github.com/anomalyco/opencode/issues/19130), the
23
- // same workaround the desktop bundle uses in packages/electron/scripts/prepare-opencode-cli.mjs.
24
- const WINDOWS_PACKAGE = '@opencode/cli-windows-x64-baseline';
21
+ // package that script downloads, the same one the desktop bundle picks in
22
+ // packages/electron/scripts/prepare-opencode-cli.mjs. x64 takes the baseline
23
+ // build so hosts without AVX2 still run it.
24
+ const windowsPackage = (arch) => (arch === 'arm64' ? '@opencode/cli-windows-arm64' : '@opencode/cli-windows-x64-baseline');
25
25
 
26
26
  export const supportsOpenCodeV2Install = (platform = process.platform) =>
27
27
  (platform === 'darwin' || platform === 'linux' || platform === 'win32') && (process.arch === 'x64' || process.arch === 'arm64');
@@ -72,8 +72,8 @@ const prepareInstallerScript = async ({ version, workDirectory, env, fetchImpl }
72
72
  * Downloads the Windows platform package, checks it against the integrity npm
73
73
  * publishes for it, and unpacks it. Returns the step that replaces the binary.
74
74
  */
75
- const prepareWindowsBinary = async ({ version, directory, workDirectory, fetchImpl, tarCommand }) => {
76
- const metadataResponse = await fetchImpl(`${NPM_REGISTRY}/${WINDOWS_PACKAGE.replace('/', '%2F')}/${version}`, { signal: AbortSignal.timeout(15_000) });
75
+ const prepareWindowsBinary = async ({ version, arch, directory, workDirectory, fetchImpl, tarCommand }) => {
76
+ const metadataResponse = await fetchImpl(`${NPM_REGISTRY}/${windowsPackage(arch).replace('/', '%2F')}/${version}`, { signal: AbortSignal.timeout(15_000) });
77
77
  if (!metadataResponse.ok) throw new Error('Could not resolve the OpenCode v2 package.');
78
78
  const { dist } = packageDistSchema.parse(await metadataResponse.json());
79
79
  if (new URL(dist.tarball).origin !== NPM_REGISTRY) throw new Error('The OpenCode v2 package is not served by the npm registry.');
@@ -100,6 +100,7 @@ export const installOpenCodeV2 = async ({
100
100
  env = process.env,
101
101
  fetchImpl = fetch,
102
102
  platform = process.platform,
103
+ arch = process.arch,
103
104
  // Windows' own bsdtar. Git Bash's GNU tar on PATH reads `C:\...` as a remote host.
104
105
  tarCommand = path.join(process.env.SystemRoot || 'C:\\Windows', 'System32', 'tar.exe'),
105
106
  } = {}) => {
@@ -119,7 +120,7 @@ export const installOpenCodeV2 = async ({
119
120
  if (!releaseResponse.ok) throw new Error('Could not resolve the OpenCode v2 release.');
120
121
  const { version } = releaseSchema.parse(await releaseResponse.json());
121
122
  const replaceBinary = windows
122
- ? await prepareWindowsBinary({ version, directory, workDirectory: lock, fetchImpl, tarCommand })
123
+ ? await prepareWindowsBinary({ version, arch, directory, workDirectory: lock, fetchImpl, tarCommand })
123
124
  : await prepareInstallerScript({ version, workDirectory: lock, env, fetchImpl });
124
125
  for (const name of binaryNames) {
125
126
  const target = path.join(directory, name);