@openchambery/web 2.0.0-beta.1 → 2.0.0-beta.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (287) hide show
  1. package/README.md +15 -11
  2. package/bin/cli-opencode.test.js +4 -4
  3. package/bin/cli.js +1 -1
  4. package/bin/lib/cli-startup.js +1 -1
  5. package/bin/lib/cli-startup.test.js +18 -4
  6. package/dist/assets/{AgentAvatar-CLWWNu_Q.js → AgentAvatar-Q1SmFRx2.js} +1 -1
  7. package/dist/assets/AssistantView-CwXkiCAd.js +3 -0
  8. package/dist/assets/{DiagramView-xuSJTnv7.js → DiagramView-BW92schO.js} +1 -1
  9. package/dist/assets/{FadeInOnReveal-Ov0D96d0.js → FadeInOnReveal-BZmwl-zE.js} +1 -1
  10. package/dist/assets/{Icon-DD-BuMfZ.js → Icon-AQPvxMiU.js} +1 -1
  11. package/dist/assets/{MarkdownRenderer-bc_p1H7l.js → MarkdownRenderer-Bv05iDZZ.js} +2 -2
  12. package/dist/assets/{MarkdownRendererImpl-SKi4oG0c.js → MarkdownRendererImpl-C81xqYib.js} +3 -3
  13. package/dist/assets/MarkstreamRendererImpl-CXUCQuKJ.js +3 -0
  14. package/dist/assets/{MobileDetailNavigation-Bnx59U_J.js → MobileDetailNavigation-BMgIu7zc.js} +1 -1
  15. package/dist/assets/{MobileShareBridge-BQlP4J-F.js → MobileShareBridge-BOXGNHmC.js} +2 -2
  16. package/dist/assets/MobileSurface-xuc_z0_G.js +2 -0
  17. package/dist/assets/{MultiRunWindow-ChZGob0v.js → MultiRunWindow-B5At2Z95.js} +1 -1
  18. package/dist/assets/{OnboardingScreen-DtQJxZG-.js → OnboardingScreen-bsPLgz4r.js} +1 -1
  19. package/dist/assets/OverlayScrollbar-4wp4migh.js +2 -0
  20. package/dist/assets/SettingsView-CJ6tcH5_.js +25 -0
  21. package/dist/assets/{SettingsWindow-DfnsFa8y.js → SettingsWindow-CMtX2bMl.js} +1 -1
  22. package/dist/assets/{TerminalView-Dh9MWCpS.js → TerminalView-Dvr-ysTS.js} +1 -1
  23. package/dist/assets/{ToolOutputDialog-BejmjrRk.js → ToolOutputDialog-uH1ZZDSS.js} +2 -2
  24. package/dist/assets/appThemeRegistry-DK7q2juD.js +1 -0
  25. package/dist/assets/{assistant-attachment-cache-C0aRnFkj.js → assistant-attachment-cache-DphJleHr.js} +1 -1
  26. package/dist/assets/{button-D-rATN7w.js → button-CcNJUxUd.js} +1 -1
  27. package/dist/assets/collapsible-ZmZ6rLWs.js +3 -0
  28. package/dist/assets/context-menu-B2LXJgXL.js +1 -0
  29. package/dist/assets/{desktopHosts-B1ixEylr.js → desktopHosts-C6HrP0jq.js} +1 -1
  30. package/dist/assets/{desktopRelayRestore-4JmFUw8r.js → desktopRelayRestore-DdVBp45U.js} +1 -1
  31. package/dist/assets/{diagram-DQf7Y_uR.js → diagram-CxTs9hCQ.js} +1 -1
  32. package/dist/assets/{dialog-bfbvR4VH.js → dialog-KMVL9QKF.js} +1 -1
  33. package/dist/assets/es-CN3E_zXj.js +15 -0
  34. package/dist/assets/{fileMentionTouchSelection-BvrNCroG.js → fileMentionTouchSelection-7RIwlpkB.js} +1 -1
  35. package/dist/assets/{fileQueries-DQc8EJUf.js → fileQueries-CjdBRy8y.js} +1 -1
  36. package/dist/assets/fr-9zZvQZD2.js +13 -0
  37. package/dist/assets/gitApi-yuWg9BN4.js +10 -0
  38. package/dist/assets/{imageSource-BlAJJUEI.js → imageSource-9vQBq3zp.js} +1 -1
  39. package/dist/assets/{input-8r6F_Fza.js → input-CrIvk3HZ.js} +1 -1
  40. package/dist/assets/insertionBoundaries-BiUCSkx-.js +1 -0
  41. package/dist/assets/ja-Dnjk_82N.js +15 -0
  42. package/dist/assets/ko-BxCEE99C.js +15 -0
  43. package/dist/assets/{main-CCE_4y5F.js → main-C9mIBo77.js} +2 -2
  44. package/dist/assets/main-DiL6w6KF.js +169 -0
  45. package/dist/assets/markdown-worker-bS5OgGO3.js +2 -0
  46. package/dist/assets/{markdownHeightCache-1Qo6y6IJ.js → markdownHeightCache-BmXVY_gc.js} +1 -1
  47. package/dist/assets/{markdownTheme-DOfGg-A7.js → markdownTheme-CKac3wKC.js} +1 -1
  48. package/dist/assets/mcpQueries-C7jknHV8.js +1 -0
  49. package/dist/assets/miniChat-CwtUaElU.js +2 -0
  50. package/dist/assets/mobile-DB-6--ws.js +2 -0
  51. package/dist/assets/multirun-TAsxFI8e.js +4 -0
  52. package/dist/assets/{persistence-Ce3AnpEr.js → persistence-56kq8Z4V.js} +2 -2
  53. package/dist/assets/pl-CNNsWnPM.js +15 -0
  54. package/dist/assets/platform-C13fMVEs.js +6 -0
  55. package/dist/assets/pt-BR-BZB2DJMp.js +15 -0
  56. package/dist/assets/{relay-image-stream-DM7atqJs.js → relay-image-stream-DrdVj99c.js} +1 -1
  57. package/dist/assets/renderElectronMiniChatApp-CGj0MNRU.js +2 -0
  58. package/dist/assets/renderMobileApp-CGMMqbsO.js +28 -0
  59. package/dist/assets/runtime-fetch-Fs9ZV2V8.js +1 -0
  60. package/dist/assets/{runtimeConfig-BMvqa5we.js → runtimeConfig-BEANkvOd.js} +1 -1
  61. package/dist/assets/runtimeEndpointReset-A_ZBUJFS.js +147 -0
  62. package/dist/assets/sessionChangeSummary-qOLrP9fw.js +2 -0
  63. package/dist/assets/{sessionLookup-gh-hVRIc.js → sessionLookup-34XFmP9C.js} +1 -1
  64. package/dist/assets/{sortable-tabs-strip-CudoSgV4.js → sortable-tabs-strip-DyEV0Heg.js} +1 -1
  65. package/dist/assets/src-ubCWXZhR.css +2 -0
  66. package/dist/assets/store-DLCiZaA6.js +15 -0
  67. package/dist/assets/{terminalApi-DiF6JmBP.js → terminalApi-CXaBEoa_.js} +1 -1
  68. package/dist/assets/textarea-BBOlRI9O.js +1 -0
  69. package/dist/assets/{tooltip-CQvpK-UV.js → tooltip-CPI4fr1a.js} +1 -1
  70. package/dist/assets/uk-rwQrxeYM.js +15 -0
  71. package/dist/assets/{url-_z7evzdK.js → url-DRDQXjyX.js} +1 -1
  72. package/dist/assets/{useAppFontEffects-Cpmpo7Wb.js → useAppFontEffects-CiDe42I5.js} +57 -43
  73. package/dist/assets/{useAssistantUIStore-CMutVh9B.js → useAssistantUIStore-asNG6K7n.js} +1 -1
  74. package/dist/assets/useDesktopWindowControlsLayout-BlizAlOt.js +1 -0
  75. package/dist/assets/useEffectiveDirectory-C_CouL5v.js +1 -0
  76. package/dist/assets/{useFontPreferences-DcbXQOkc.js → useFontPreferences-B0oUZAiB.js} +1 -1
  77. package/dist/assets/useMobileNavigationStore-DfPClaW-.js +414 -0
  78. package/dist/assets/{useRuntimeAPIs-DSLXJybW.js → useRuntimeAPIs-DwLtO0c_.js} +1 -1
  79. package/dist/assets/useSessionAutoCleanup-DtrfbxAp.js +2 -0
  80. package/dist/assets/{useThemeSystem-DMzWVJaW.js → useThemeSystem-BlNUWH47.js} +1 -1
  81. package/dist/assets/useUIStore-CQYKJnrg.js +1 -0
  82. package/dist/assets/{useWorkerHighlightedLines-Bqn4AiBL.js → useWorkerHighlightedLines-CPNYz6Wf.js} +2 -2
  83. package/dist/assets/{vendor-base-ui-CnHfUmDN.js → vendor-base-ui-CDjyofeD.js} +1 -1
  84. package/dist/assets/{vendor-cmdk-CeAGfrLo.js → vendor-cmdk-BFH0UpXX.js} +1 -1
  85. package/dist/assets/{vendor-dnd-kit-accessibility-BY5LhhUB.js → vendor-dnd-kit-accessibility-BrR3AKvM.js} +1 -1
  86. package/dist/assets/{vendor-dnd-kit-core-CvWxUPdb.js → vendor-dnd-kit-core-CNfdvk6H.js} +1 -1
  87. package/dist/assets/{vendor-dnd-kit-sortable-hHdP9lLb.js → vendor-dnd-kit-sortable-DQUaTeGj.js} +1 -1
  88. package/dist/assets/{vendor-framer-motion-DFy_BSVd.js → vendor-framer-motion-4dogsUk3.js} +1 -1
  89. package/dist/assets/{vendor-legendapp-list-WE32lwNk.js → vendor-legendapp-list-BU7MdPZR.js} +1 -1
  90. package/dist/assets/{vendor-markstream-react-DX55XLGO.js → vendor-markstream-react-qHC2FykU.js} +3 -3
  91. package/dist/assets/{vendor-pierre-diffs-DX7XTPG3.js → vendor-pierre-diffs-CUfqC003.js} +1 -1
  92. package/dist/assets/{vendor-react-DLPElpoQ.js → vendor-react-C94A0AHl.js} +1 -1
  93. package/dist/assets/{vendor-react-drawio-ECtEwvbc.js → vendor-react-drawio-FVlWqKVE.js} +1 -1
  94. package/dist/assets/{vendor-reactuses-core-BnW6GxJt.js → vendor-reactuses-core-C2A3EjUi.js} +1 -1
  95. package/dist/assets/{vendor-sonner-DVzioF3d.js → vendor-sonner-DNqHmUQs.js} +1 -1
  96. package/dist/assets/{vendor-stream-diffs-DWy3xSmp.js → vendor-stream-diffs-CwbKsmlv.js} +2 -2
  97. package/dist/assets/{vendor-tanstack-react-query-D7pys_c9.js → vendor-tanstack-react-query-D0iFK57C.js} +1 -1
  98. package/dist/assets/{vendor-tanstack-react-virtual-tlDgH6PG.js → vendor-tanstack-react-virtual-B2kV6cnT.js} +1 -1
  99. package/dist/assets/{vendor-zustand-B4dlGEM2.js → vendor-zustand-BqLKf6ZY.js} +1 -1
  100. package/dist/assets/zh-CN-BvmBDnkD.js +15 -0
  101. package/dist/assets/zh-TW-9wcHTX0y.js +15 -0
  102. package/dist/index.html +13 -13
  103. package/dist/mini-chat.html +13 -13
  104. package/dist/mobile.html +13 -13
  105. package/package.json +1 -1
  106. package/server/index.js +327 -62
  107. package/server/lib/assistants/DOCUMENTATION.md +14 -9
  108. package/server/lib/assistants/contracts.js +11 -1
  109. package/server/lib/assistants/projection-history.test.js +265 -18
  110. package/server/lib/assistants/routes.js +6 -0
  111. package/server/lib/assistants/service.js +417 -231
  112. package/server/lib/assistants/service.test.js +392 -148
  113. package/server/lib/conversations/DOCUMENTATION.md +1 -1
  114. package/server/lib/conversations/service.js +1 -0
  115. package/server/lib/conversations/service.test.js +16 -0
  116. package/server/lib/conversations/validation.js +11 -1
  117. package/server/lib/event-stream/DOCUMENTATION.md +3 -1
  118. package/server/lib/event-stream/directory-ws-bridge.js +13 -2
  119. package/server/lib/event-stream/global-hub.js +49 -4
  120. package/server/lib/event-stream/reasoning-projection.js +16 -1
  121. package/server/lib/event-stream/reasoning-projection.test.js +15 -0
  122. package/server/lib/event-stream/runtime.js +57 -20
  123. package/server/lib/event-stream/runtime.test.js +112 -0
  124. package/server/lib/llm/DOCUMENTATION.md +33 -9
  125. package/server/lib/llm/catalog.js +1 -1
  126. package/server/lib/llm/completions.js +5 -1
  127. package/server/lib/llm/completions.test.js +3 -3
  128. package/server/lib/llm/generate.js +237 -145
  129. package/server/lib/llm/generate.test.js +201 -46
  130. package/server/lib/llm/routes.js +2 -0
  131. package/server/lib/message-queue/runtime.js +15 -2
  132. package/server/lib/notifications/runtime.js +23 -5
  133. package/server/lib/notifications/runtime.test.js +11 -0
  134. package/server/lib/opencode/DOCUMENTATION.md +85 -17
  135. package/server/lib/opencode/agent-document.d.ts +28 -0
  136. package/server/lib/opencode/agent-document.js +280 -0
  137. package/server/lib/opencode/agent-document.test.js +137 -0
  138. package/server/lib/opencode/agents.js +96 -0
  139. package/server/lib/opencode/auth-state-runtime.js +5 -0
  140. package/server/lib/opencode/bootstrap-runtime.js +2 -0
  141. package/server/lib/opencode/config-entity-routes.js +42 -99
  142. package/server/lib/opencode/config-entity-routes.test.js +107 -0
  143. package/server/lib/opencode/config-hot-reload.integration.test.js +105 -0
  144. package/server/lib/opencode/ensure-cli.js +1 -2
  145. package/server/lib/opencode/env-runtime.js +40 -26
  146. package/server/lib/opencode/env-runtime.test.js +10 -17
  147. package/server/lib/opencode/feature-routes-runtime.js +43 -4
  148. package/server/lib/opencode/feature-routes-runtime.test.js +18 -0
  149. package/server/lib/opencode/http-server-connections.js +98 -0
  150. package/server/lib/opencode/http-server-connections.test.js +321 -0
  151. package/server/lib/opencode/lifecycle.js +349 -39
  152. package/server/lib/opencode/lifecycle.test.js +322 -0
  153. package/server/lib/opencode/managed-capabilities-runtime.js +15 -2
  154. package/server/lib/opencode/managed-capabilities-runtime.test.js +1 -0
  155. package/server/lib/opencode/openchamber-routes.js +7 -1
  156. package/server/lib/opencode/opencode2-pin.js +4 -4
  157. package/server/lib/opencode/opencode2-pin.test.js +1 -0
  158. package/server/lib/opencode/opencode2-sidecar.e2e.test.js +127 -4
  159. package/server/lib/opencode/owned-runtime-upgrade.js +431 -0
  160. package/server/lib/opencode/owned-runtime-upgrade.test.js +160 -0
  161. package/server/lib/opencode/plugin-routes.js +3 -18
  162. package/server/lib/opencode/plugin-routes.test.js +9 -9
  163. package/server/lib/opencode/provider-catalog.js +58 -12
  164. package/server/lib/opencode/provider-catalog.test.js +48 -0
  165. package/server/lib/opencode/proxy.js +252 -46
  166. package/server/lib/opencode/proxy.test.js +235 -2
  167. package/server/lib/opencode/routes.js +296 -62
  168. package/server/lib/opencode/routes.test.js +278 -12
  169. package/server/lib/opencode/runtime-contract.js +401 -0
  170. package/server/lib/opencode/runtime-contract.test.js +214 -0
  171. package/server/lib/opencode/server-opencode-fetch.js +242 -0
  172. package/server/lib/opencode/server-opencode-fetch.test.js +130 -0
  173. package/server/lib/opencode/server-utils-runtime.js +33 -0
  174. package/server/lib/opencode/shared-service.integration.test.js +66 -0
  175. package/server/lib/opencode/shared-service.js +147 -0
  176. package/server/lib/opencode/shared-service.test.js +93 -0
  177. package/server/lib/opencode/shared.js +3 -3
  178. package/server/lib/opencode/shutdown-runtime.js +15 -6
  179. package/server/lib/opencode/shutdown-runtime.test.js +49 -1
  180. package/server/lib/opencode/skill-routes.js +20 -33
  181. package/server/lib/opencode/skill-routes.test.js +35 -2
  182. package/server/lib/opencode/skills.test.js +15 -0
  183. package/server/lib/opencode/startup-pipeline-runtime.js +4 -0
  184. package/server/lib/opencode/static-routes-runtime.js +3 -1
  185. package/server/lib/opencode/static-routes-runtime.test.js +33 -0
  186. package/server/lib/opencode/v2-client.js +14 -3
  187. package/server/lib/opencode/v2-plugin-host-shim/index.mjs +155 -0
  188. package/server/lib/opencode/v2-plugin-host-shim/package.json +4 -0
  189. package/server/lib/permission-auto-accept/DOCUMENTATION.md +5 -5
  190. package/server/lib/permission-auto-accept/runtime.js +14 -7
  191. package/server/lib/permission-auto-accept/runtime.test.js +27 -12
  192. package/server/lib/relay/reasoning-projection.e2e.test.ts +22 -7
  193. package/server/lib/scheduled-tasks/DOCUMENTATION.md +8 -0
  194. package/server/lib/scheduled-tasks/runtime.js +52 -4
  195. package/server/lib/scheduled-tasks/runtime.test.js +80 -1
  196. package/server/lib/session-goal/DOCUMENTATION.md +113 -13
  197. package/server/lib/session-goal/routes.js +9 -0
  198. package/server/lib/session-goal/runtime.js +1579 -117
  199. package/server/lib/session-goal/runtime.test.js +1453 -84
  200. package/server/lib/session-index/DOCUMENTATION.md +18 -15
  201. package/server/lib/session-index/event-ingest.js +89 -16
  202. package/server/lib/session-index/event-ingest.test.js +73 -0
  203. package/server/lib/session-index/host-authority-snapshot.js +92 -0
  204. package/server/lib/session-index/routes.host-archive.test.js +201 -0
  205. package/server/lib/session-index/routes.js +63 -10
  206. package/server/lib/session-index/routes.test.js +8 -8
  207. package/server/lib/session-index/service.js +7 -1
  208. package/server/lib/session-index/sync-runtime.js +161 -34
  209. package/server/lib/session-index/sync-runtime.test.js +155 -1
  210. package/server/lib/session-metadata/DOCUMENTATION.md +61 -16
  211. package/server/lib/session-metadata/routes.js +108 -9
  212. package/server/lib/session-metadata/routes.test.js +47 -2
  213. package/server/lib/session-metadata/session-archive.d.ts +73 -0
  214. package/server/lib/session-metadata/session-archive.js +654 -0
  215. package/server/lib/session-metadata/session-archive.test.js +359 -0
  216. package/server/lib/session-metadata/session-metadata-store.d.ts +36 -0
  217. package/server/lib/session-metadata/session-metadata-store.js +212 -72
  218. package/server/lib/session-metadata/session-metadata-store.test.js +205 -10
  219. package/server/lib/session-metadata/session-projection.d.ts +41 -0
  220. package/server/lib/session-metadata/session-projection.js +255 -0
  221. package/server/lib/session-metadata/session-projection.test.js +64 -0
  222. package/server/lib/session-metadata/system-session.js +55 -0
  223. package/server/lib/session-metadata/system-session.test.js +55 -0
  224. package/server/lib/session-title/DOCUMENTATION.md +40 -7
  225. package/server/lib/session-title/native-runtime.test.js +167 -0
  226. package/server/lib/session-title/runtime.js +67 -42
  227. package/server/lib/session-title/session-access.js +46 -0
  228. package/server/lib/session-title/session-access.test.js +91 -0
  229. package/server/lib/session-turn-pages/DOCUMENTATION.md +9 -8
  230. package/server/lib/session-turn-pages/routes.js +17 -102
  231. package/server/lib/session-turn-pages/routes.test.js +281 -0
  232. package/server/lib/session-turn-pages/session-message-projection.d.ts +13 -0
  233. package/server/lib/session-turn-pages/session-message-projection.js +281 -0
  234. package/server/lib/small-model/DOCUMENTATION.md +101 -147
  235. package/server/lib/small-model/catalog.js +31 -51
  236. package/server/lib/small-model/catalog.test.js +65 -10
  237. package/server/lib/small-model/custom-api.js +68 -0
  238. package/server/lib/small-model/custom-api.test.js +64 -0
  239. package/server/lib/small-model/index.js +124 -240
  240. package/server/lib/small-model/resolve.js +35 -120
  241. package/server/lib/small-model/resolve.test.js +20 -110
  242. package/server/lib/small-model/routes.js +1 -3
  243. package/server/lib/small-model/summary-settings.test.js +157 -253
  244. package/server/opencode-proxy.test.js +2 -0
  245. package/dist/assets/AssistantView-BTCkm8yk.js +0 -3
  246. package/dist/assets/MarkstreamRendererImpl-CnkodGJW.js +0 -3
  247. package/dist/assets/MobileSurface-BXLeiZ4d.js +0 -2
  248. package/dist/assets/OverlayScrollbar-pMwxaCca.js +0 -2
  249. package/dist/assets/SettingsView-B5I0KDTZ.js +0 -25
  250. package/dist/assets/appThemeRegistry-COGfxTFV.js +0 -1
  251. package/dist/assets/collapsible-BXRd5_Hr.js +0 -1
  252. package/dist/assets/context-menu-jGtH6fR9.js +0 -1
  253. package/dist/assets/es-c52LTHHe.js +0 -15
  254. package/dist/assets/fr-PisFbUE8.js +0 -13
  255. package/dist/assets/gitApi-ButwzQ0N.js +0 -9
  256. package/dist/assets/insertionBoundaries-DGs7S5zv.js +0 -1
  257. package/dist/assets/ja-C8Ikun3Z.js +0 -15
  258. package/dist/assets/ko-ClFys5o2.js +0 -15
  259. package/dist/assets/main-Dn9rpsgl.js +0 -169
  260. package/dist/assets/markdown-worker-CptoICmq.js +0 -2
  261. package/dist/assets/miniChat-BpjxVqgg.js +0 -2
  262. package/dist/assets/mobile-nMJCaNq1.js +0 -2
  263. package/dist/assets/multirun-BvSG94mW.js +0 -4
  264. package/dist/assets/pl-CnaKWN5F.js +0 -15
  265. package/dist/assets/platform-CWTIp02q.js +0 -4
  266. package/dist/assets/projectMeta-DOe49dM_.js +0 -3
  267. package/dist/assets/pt-BR-s8OpfRI6.js +0 -15
  268. package/dist/assets/renderElectronMiniChatApp-R6dKh79f.js +0 -2
  269. package/dist/assets/renderMobileApp-y-S8y8v_.js +0 -28
  270. package/dist/assets/runtime-fetch-zIalUnbb.js +0 -1
  271. package/dist/assets/runtimeEndpointReset-B6BxhvR0.js +0 -147
  272. package/dist/assets/sessionChangeSummary-VVBlEXuh.js +0 -2
  273. package/dist/assets/src-BzX5UI-c.css +0 -2
  274. package/dist/assets/store-DJczmczv.js +0 -15
  275. package/dist/assets/textarea-3XR8sUfy.js +0 -1
  276. package/dist/assets/uk-CaX0iqkB.js +0 -15
  277. package/dist/assets/useDesktopWindowControlsLayout-B1GX59-L.js +0 -1
  278. package/dist/assets/useEffectiveDirectory-CN6JoCFw.js +0 -1
  279. package/dist/assets/useMobileNavigationStore-BVjFtXSy.js +0 -414
  280. package/dist/assets/useSessionAutoCleanup-BLjymaFt.js +0 -2
  281. package/dist/assets/useUIStore-Bxv3f23A.js +0 -1
  282. package/dist/assets/zh-CN-20MYEjha.js +0 -15
  283. package/dist/assets/zh-TW-DiN04qTw.js +0 -15
  284. package/server/lib/small-model/call.js +0 -427
  285. package/server/lib/small-model/call.test.js +0 -382
  286. package/server/lib/small-model/opencode-session.js +0 -183
  287. package/server/lib/small-model/opencode-session.test.js +0 -147
@@ -16,6 +16,7 @@ const originalPath = process.env.PATH;
16
16
 
17
17
  afterEach(() => {
18
18
  spawnMock.mockReset();
19
+ vi.useRealTimers();
19
20
  vi.unstubAllGlobals();
20
21
  if (typeof originalOpencodeBinary === 'string') {
21
22
  process.env.OPENCODE_BINARY = originalOpencodeBinary;
@@ -182,6 +183,103 @@ describe('OpenCode lifecycle', () => {
182
183
  await server.close();
183
184
  });
184
185
 
186
+ it('allows a polite SIGTERM close that finishes after 400ms without escalating early', async () => {
187
+ vi.useFakeTimers();
188
+ delete process.env.OPENCODE_BINARY;
189
+ const child = createMockChild();
190
+ child.kill = vi.fn((signal) => {
191
+ if (signal === 'SIGTERM') {
192
+ // Slow-but-healthy flush: exits after 800ms (>400ms), still under 2500ms grace.
193
+ setTimeout(() => {
194
+ child.signalCode = 'SIGTERM';
195
+ child.emit('close', null, 'SIGTERM');
196
+ }, 800);
197
+ }
198
+ return true;
199
+ });
200
+ spawnMock.mockImplementationOnce(() => {
201
+ queueMicrotask(() => {
202
+ child.stdout.emit('data', 'server listening on http://127.0.0.1:45678\n');
203
+ });
204
+ return child;
205
+ });
206
+ stubOpenCodeFetch();
207
+
208
+ const runtime = createRuntime();
209
+ const server = await runtime.startOpenCode();
210
+ const closePromise = server.close();
211
+
212
+ await vi.advanceTimersByTimeAsync(400);
213
+ expect(child.kill).toHaveBeenCalledWith('SIGTERM');
214
+ expect(child.kill).not.toHaveBeenCalledWith('SIGKILL');
215
+
216
+ await vi.advanceTimersByTimeAsync(400);
217
+ await closePromise;
218
+ expect(child.kill).not.toHaveBeenCalledWith('SIGKILL');
219
+ vi.useRealTimers();
220
+ });
221
+
222
+ it('escalates a hung managed child to SIGKILL only after the full 2500ms SIGTERM grace', async () => {
223
+ vi.useFakeTimers();
224
+ delete process.env.OPENCODE_BINARY;
225
+ const child = createMockChild();
226
+ child.kill = vi.fn((signal) => {
227
+ if (signal === 'SIGKILL') {
228
+ child.signalCode = 'SIGKILL';
229
+ queueMicrotask(() => child.emit('close', null, 'SIGKILL'));
230
+ }
231
+ return true;
232
+ });
233
+ spawnMock.mockImplementationOnce(() => {
234
+ queueMicrotask(() => {
235
+ child.stdout.emit('data', 'server listening on http://127.0.0.1:45678\n');
236
+ });
237
+ return child;
238
+ });
239
+ stubOpenCodeFetch();
240
+
241
+ const runtime = createRuntime();
242
+ const server = await runtime.startOpenCode();
243
+ const closePromise = server.close();
244
+
245
+ await vi.advanceTimersByTimeAsync(2499);
246
+ expect(child.kill).toHaveBeenCalledWith('SIGTERM');
247
+ expect(child.kill).not.toHaveBeenCalledWith('SIGKILL');
248
+
249
+ await vi.advanceTimersByTimeAsync(1);
250
+ expect(child.kill).toHaveBeenCalledWith('SIGKILL');
251
+ await vi.advanceTimersByTimeAsync(1000);
252
+ await closePromise;
253
+ vi.useRealTimers();
254
+ });
255
+
256
+ it('killProcessOnPort only signals the owned pid and never mass-kills by port', async () => {
257
+ const killSpy = vi.spyOn(process, 'kill').mockImplementation(() => true);
258
+ delete process.env.OPENCODE_BINARY;
259
+ const child = createMockChild();
260
+ spawnMock.mockImplementationOnce(() => {
261
+ queueMicrotask(() => {
262
+ child.stdout.emit('data', 'server listening on http://127.0.0.1:45678\n');
263
+ });
264
+ return child;
265
+ });
266
+ stubOpenCodeFetch();
267
+
268
+ const runtime = createRuntime();
269
+ await runtime.startOpenCode();
270
+ killSpy.mockClear();
271
+
272
+ runtime.killProcessOnPort(45678);
273
+ expect(killSpy).not.toHaveBeenCalled();
274
+
275
+ runtime.killProcessOnPort(45678, 4242);
276
+ expect(killSpy).toHaveBeenCalledWith(-4242, 'SIGKILL');
277
+ expect(killSpy).toHaveBeenCalledWith(4242, 'SIGKILL');
278
+ expect(killSpy.mock.calls.every(([target]) => target === -4242 || target === 4242)).toBe(true);
279
+
280
+ killSpy.mockRestore();
281
+ });
282
+
185
283
  it('parses a v2 server listening line without the opencode prefix', async () => {
186
284
  delete process.env.OPENCODE_BINARY;
187
285
  const child = createMockChild();
@@ -741,6 +839,54 @@ describe('OpenCode lifecycle', () => {
741
839
  expect(stateRef.current.isOpenCodeReady).toBe(false);
742
840
  }, 15_000);
743
841
 
842
+ it('revokes execution permit on restart and ignores stale async health for a new generation', async () => {
843
+ const stateRef = {};
844
+ const runtime = createRuntime({}, stateRef);
845
+ stateRef.current.openCodePort = 45678;
846
+ stateRef.current.runtimeContractGeneration = 1;
847
+ stateRef.current.runtimeContract = {
848
+ executionAllowed: true,
849
+ phase: 'ready',
850
+ serveVersion: '2.0.12',
851
+ instanceGeneration: 1,
852
+ };
853
+
854
+ const genBefore = runtime.revokeRuntimeExecutionPermit('test-restart');
855
+ expect(genBefore).toBe(2);
856
+ expect(stateRef.current.runtimeContract.executionAllowed).toBe(false);
857
+ expect(stateRef.current.runtimeContract.phase).toBe('pending');
858
+ expect(stateRef.current.runtimeContract.instanceGeneration).toBe(2);
859
+
860
+ // Stale probe from generation 1 must not re-open execution on generation 2.
861
+ runtime.refreshRuntimeContractFromProbe(
862
+ {
863
+ ok: true,
864
+ version: '2.0.12',
865
+ authenticated: true,
866
+ healthOk: true,
867
+ },
868
+ { admitTranscript: true, phase: 'done', error: null },
869
+ 1,
870
+ );
871
+ expect(stateRef.current.runtimeContract.executionAllowed).toBe(false);
872
+ expect(stateRef.current.runtimeContract.instanceGeneration).toBe(2);
873
+
874
+ // Matching generation publishes verified permit.
875
+ runtime.refreshRuntimeContractFromProbe(
876
+ {
877
+ ok: true,
878
+ version: '2.0.12',
879
+ authenticated: true,
880
+ healthOk: true,
881
+ },
882
+ { admitTranscript: true, phase: 'done', error: null },
883
+ 2,
884
+ );
885
+ expect(stateRef.current.runtimeContract.executionAllowed).toBe(true);
886
+ expect(stateRef.current.runtimeContract.instanceGeneration).toBe(2);
887
+ expect(stateRef.current.runtimeContract.serveVersion).toBe('2.0.12');
888
+ });
889
+
744
890
  it('does not mark external skip-start ready before version and migration admit', async () => {
745
891
  const stateRef = {};
746
892
  const runtime = createRuntime({
@@ -764,3 +910,179 @@ describe('OpenCode lifecycle', () => {
764
910
  expect(stateRef.current.openCodePort).toBe(3001);
765
911
  }, 25_000);
766
912
  });
913
+
914
+ describe('shared official OpenCode service', () => {
915
+ const UNCONFIGURED_ENV = {
916
+ ENV_CONFIGURED_OPENCODE_PORT: null,
917
+ ENV_CONFIGURED_OPENCODE_HOST: null,
918
+ ENV_EFFECTIVE_PORT: null,
919
+ ENV_CONFIGURED_OPENCODE_HOSTNAME: '127.0.0.1',
920
+ ENV_SKIP_OPENCODE_START: false,
921
+ };
922
+ const REGISTRATION = {
923
+ origin: 'http://127.0.0.1:49374',
924
+ port: 49374,
925
+ pid: process.pid,
926
+ password: 'service-password',
927
+ version: '2.0.12',
928
+ };
929
+
930
+ const createSharedService = (overrides = {}) => ({
931
+ registrationFile: '/state/opencode/service.json',
932
+ buildStartEnv: (env) => {
933
+ const { OPENCODE_SERVER_PASSWORD: _password, ...rest } = env;
934
+ return rest;
935
+ },
936
+ start: vi.fn(async () => {}),
937
+ readRegistration: vi.fn(async () => REGISTRATION),
938
+ ...overrides,
939
+ });
940
+
941
+ const stubSharedFetch = () => {
942
+ const base = stubOpenCodeFetch();
943
+ const fetchMock = vi.fn(async (url, init) => (String(url).includes('/api/location/reload')
944
+ ? new Response(null, { status: 204 })
945
+ : base(url, init)));
946
+ vi.stubGlobal('fetch', fetchMock);
947
+ return fetchMock;
948
+ };
949
+
950
+ it('attaches to the registered service instead of spawning a private serve', async () => {
951
+ process.env.OPENCODE_BINARY = '/mock/opencode';
952
+ const sharedService = createSharedService();
953
+ const adoptOpenCodeServerPassword = vi.fn();
954
+ const stateRef = {};
955
+ const runtime = createRuntime({ env: UNCONFIGURED_ENV, sharedService, adoptOpenCodeServerPassword }, stateRef);
956
+ const fetchMock = stubSharedFetch();
957
+
958
+ await runtime.bootstrapOpenCodeAtStartup();
959
+
960
+ expect(spawnMock).not.toHaveBeenCalled();
961
+ expect(sharedService.start).toHaveBeenCalledTimes(1);
962
+ const startInput = sharedService.start.mock.calls[0][0];
963
+ expect(startInput.binary).toBe('/mock/opencode');
964
+ expect(startInput.env.OPENCODE_SERVER_PASSWORD).toBeUndefined();
965
+ expect(adoptOpenCodeServerPassword).toHaveBeenCalledWith('service-password', 'shared-service');
966
+ expect(stateRef.current.isExternalOpenCode).toBe(true);
967
+ expect(stateRef.current.openCodeBaseUrl).toBe('http://127.0.0.1:49374');
968
+ expect(stateRef.current.openCodePort).toBe(49374);
969
+ expect(runtime.isSharedOpenCodeService()).toBe(true);
970
+ // The leftover private serve on 4096 is never probed once the service wins.
971
+ expect(fetchMock.mock.calls.some(([url]) => String(url).includes(':4096/'))).toBe(false);
972
+ });
973
+
974
+ it('reports shared startup failure without probing or spawning a private serve, and can retry', async () => {
975
+ process.env.OPENCODE_BINARY = '/mock/opencode';
976
+ const sharedService = createSharedService({ start: vi.fn(async () => { throw new Error('boom'); }) });
977
+ const adoptOpenCodeServerPassword = vi.fn();
978
+ const stateRef = {};
979
+ const runtime = createRuntime({ env: UNCONFIGURED_ENV, sharedService, adoptOpenCodeServerPassword }, stateRef);
980
+ vi.spyOn(console, 'warn').mockImplementation(() => {});
981
+ const fetchMock = stubSharedFetch();
982
+
983
+ await runtime.bootstrapOpenCodeAtStartup();
984
+
985
+ expect(adoptOpenCodeServerPassword).toHaveBeenLastCalledWith(null, null);
986
+ expect(runtime.isSharedOpenCodeService()).toBe(false);
987
+ expect(stateRef.current.isExternalOpenCode).toBe(false);
988
+ expect(stateRef.current.openCodeBaseUrl).toBeNull();
989
+ expect(stateRef.current.openCodeProcess).toBeNull();
990
+ expect(stateRef.current.isOpenCodeReady).toBe(false);
991
+ expect(stateRef.current.lastOpenCodeError).toBe('boom');
992
+ expect(spawnMock).not.toHaveBeenCalled();
993
+ expect(fetchMock).not.toHaveBeenCalled();
994
+
995
+ sharedService.start.mockResolvedValue(undefined);
996
+ await runtime.bootstrapOpenCodeAtStartup();
997
+ expect(runtime.isSharedOpenCodeService()).toBe(true);
998
+ expect(stateRef.current.isOpenCodeReady).toBe(true);
999
+ });
1000
+
1001
+ it('reconnects using updated registration without replacing the service', async () => {
1002
+ const sharedService = createSharedService();
1003
+ const adoptOpenCodeServerPassword = vi.fn();
1004
+ const stateRef = {};
1005
+ const runtime = createRuntime({ env: UNCONFIGURED_ENV, sharedService, adoptOpenCodeServerPassword }, stateRef);
1006
+ stubSharedFetch();
1007
+ await runtime.bootstrapOpenCodeAtStartup();
1008
+ sharedService.readRegistration.mockResolvedValue({ ...REGISTRATION, port: 49375, origin: 'http://127.0.0.1:49375', password: 'rotated' });
1009
+
1010
+ await runtime.restartOpenCode();
1011
+
1012
+ expect(sharedService.start.mock.lastCall[0].replace).toBe(false);
1013
+ expect(stateRef.current.openCodePort).toBe(49375);
1014
+ expect(adoptOpenCodeServerPassword).toHaveBeenLastCalledWith('rotated', 'shared-service');
1015
+ expect(spawnMock).not.toHaveBeenCalled();
1016
+ });
1017
+
1018
+ it('reattaches shared ownership across HMR and retires an old private child', async () => {
1019
+ const sharedService = createSharedService();
1020
+ const stateRef = {};
1021
+ const runtime = createRuntime({ env: UNCONFIGURED_ENV, sharedService }, stateRef);
1022
+ const close = vi.fn();
1023
+ stateRef.current.openCodeProcess = { pid: 12345, close };
1024
+ stateRef.current.openCodePort = 4096;
1025
+ stubSharedFetch();
1026
+ await runtime.bootstrapOpenCodeAtStartup();
1027
+ expect(close).toHaveBeenCalledOnce();
1028
+ expect(stateRef.current.openCodeProcess).toBeNull();
1029
+ expect(runtime.isSharedOpenCodeService()).toBe(true);
1030
+ await runtime.bootstrapOpenCodeAtStartup();
1031
+ expect(runtime.isSharedOpenCodeService()).toBe(true);
1032
+ expect(stateRef.current.isExternalOpenCode).toBe(true);
1033
+ expect(spawnMock).not.toHaveBeenCalled();
1034
+ });
1035
+
1036
+ it('uses the explicit upgrade binary without settings rediscovery and propagates replacement failures', async () => {
1037
+ const sharedService = createSharedService();
1038
+ const applyOpencodeBinaryFromSettings = vi.fn();
1039
+ const stateRef = {};
1040
+ const runtime = createRuntime({ env: UNCONFIGURED_ENV, sharedService, applyOpencodeBinaryFromSettings }, stateRef);
1041
+ stubSharedFetch();
1042
+ await runtime.bootstrapOpenCodeAtStartup();
1043
+ applyOpencodeBinaryFromSettings.mockClear();
1044
+
1045
+ await runtime.restartOpenCode({ binaryPath: '/cache/opencode-cli/2.0.14/opencode' });
1046
+
1047
+ expect(sharedService.start.mock.lastCall[0]).toMatchObject({ binary: '/cache/opencode-cli/2.0.14/opencode', replace: true });
1048
+ expect(applyOpencodeBinaryFromSettings).not.toHaveBeenCalled();
1049
+ sharedService.start.mockRejectedValue(new Error('replacement failed'));
1050
+ await expect(runtime.restartOpenCode({ binaryPath: '/cache/opencode-cli/2.0.14/opencode' })).rejects.toThrow('replacement failed');
1051
+ expect(stateRef.current.isOpenCodeReady).toBe(false);
1052
+ expect(stateRef.current.runtimeContract.executionAllowed).toBe(false);
1053
+ expect(spawnMock).not.toHaveBeenCalled();
1054
+ });
1055
+
1056
+ it('reloads config changes in place instead of restarting the shared service', async () => {
1057
+ process.env.OPENCODE_BINARY = '/mock/opencode';
1058
+ const sharedService = createSharedService();
1059
+ const stateRef = {};
1060
+ const runtime = createRuntime({ env: UNCONFIGURED_ENV, sharedService, adoptOpenCodeServerPassword: vi.fn() }, stateRef);
1061
+ const fetchMock = stubSharedFetch();
1062
+ await runtime.bootstrapOpenCodeAtStartup();
1063
+ fetchMock.mockClear();
1064
+
1065
+ const result = await runtime.refreshOpenCodeAfterConfigChange('plugin add');
1066
+
1067
+ expect(result).toEqual({ reloaded: true, external: true });
1068
+ const reloadCalls = fetchMock.mock.calls.filter(([url]) => String(url).includes('/api/location/reload'));
1069
+ expect(reloadCalls).toHaveLength(1);
1070
+ expect(reloadCalls[0][1]?.method).toBe('POST');
1071
+ expect(spawnMock).not.toHaveBeenCalled();
1072
+ expect(sharedService.start).toHaveBeenCalledTimes(1);
1073
+ });
1074
+
1075
+ it('keeps an explicitly configured OpenCode port instead of the shared service', async () => {
1076
+ const sharedService = createSharedService();
1077
+ const stateRef = {};
1078
+ const runtime = createRuntime({ sharedService, adoptOpenCodeServerPassword: vi.fn() }, stateRef);
1079
+ stubSharedFetch();
1080
+ startListeningChild();
1081
+
1082
+ await runtime.bootstrapOpenCodeAtStartup();
1083
+
1084
+ expect(sharedService.start).not.toHaveBeenCalled();
1085
+ expect(runtime.isSharedOpenCodeService()).toBe(false);
1086
+ await stateRef.current.openCodeProcess?.close();
1087
+ });
1088
+ });
@@ -122,15 +122,28 @@ export const createManagedCapabilitiesRuntime = ({
122
122
  bridgeOrigin = parsed.origin;
123
123
  };
124
124
 
125
+ const publishHostShim = async () => {
126
+ const sourceDir = pathLike.join(pathLike.dirname(fileURLToPath(import.meta.url)), 'v2-plugin-host-shim');
127
+ const target = pathLike.join(dataDir, 'managed-opencode-capabilities', 'v2-plugin-host-shim');
128
+ await fsLike.mkdir(target, { recursive: true });
129
+ await Promise.all(['package.json', 'index.mjs'].map((name) => fsLike.copyFile(pathLike.join(sourceDir, name), pathLike.join(target, name))));
130
+ return target;
131
+ };
132
+
125
133
  const prepareManagedChildEnv = async (baseEnv = {}) => {
126
134
  if (!bridgeOrigin) throw new Error('Managed scheduled-task bridge origin is unavailable');
127
- const { pluginPath, instructionsPath } = await publishResources();
135
+ const [{ pluginPath, instructionsPath }, shimDirectory] = await Promise.all([
136
+ publishResources(),
137
+ publishHostShim(),
138
+ ]);
128
139
  const token = cryptoLike.randomBytes(32).toString('hex');
129
140
  const pluginUrl = pathToFileURL(pluginPath).href;
130
141
  identity = { version: materialVersion, origin: bridgeOrigin, token, childPid: null };
142
+ const merged = JSON.parse(mergeManagedOpenCodeConfig({ configContent: baseEnv.OPENCODE_CONFIG_CONTENT, pluginUrl, instructionsUrl: instructionsPath }));
143
+ merged.plugin = stableDedupe([...(Array.isArray(merged.plugin) ? merged.plugin : []), shimDirectory]);
131
144
  return {
132
145
  ...baseEnv,
133
- OPENCODE_CONFIG_CONTENT: mergeManagedOpenCodeConfig({ configContent: baseEnv.OPENCODE_CONFIG_CONTENT, pluginUrl, instructionsUrl: instructionsPath }),
146
+ OPENCODE_CONFIG_CONTENT: JSON.stringify(merged),
134
147
  OPENCHAMBER_SCHEDULED_TASK_BRIDGE_ORIGIN: bridgeOrigin,
135
148
  OPENCHAMBER_SCHEDULED_TASK_BRIDGE_PATH: MANAGED_SCHEDULED_TASK_TOOL_PATH,
136
149
  OPENCHAMBER_SCHEDULED_TASK_TOKEN_HEADER: MANAGED_SCHEDULED_TASK_TOKEN_HEADER,
@@ -24,6 +24,7 @@ describe('managed OpenCode capabilities runtime', () => {
24
24
  runtime.recordManagedChildPid(44);
25
25
  const identity = runtime.getCapabilityIdentity();
26
26
  expect(childEnv.OPENCODE_CONFIG_CONTENT).toContain('scheduled-task.mjs');
27
+ expect(JSON.parse(childEnv.OPENCODE_CONFIG_CONTENT).plugin).toContain(path.join(dataDir, 'managed-opencode-capabilities', 'v2-plugin-host-shim'));
27
28
  expect(JSON.parse(childEnv.OPENCODE_CONFIG_CONTENT).instructions[0]).toMatch(/^\//);
28
29
  expect(childEnv.OPENCHAMBER_SCHEDULED_TASK_BRIDGE_PATH).toBe(MANAGED_SCHEDULED_TASK_TOOL_PATH);
29
30
  expect(childEnv.OPENCHAMBER_SCHEDULED_TASK_TOKEN_HEADER).toBe(MANAGED_SCHEDULED_TASK_TOKEN_HEADER);
@@ -14,11 +14,17 @@ export const registerOpenChamberRoutes = (app, dependencies) => {
14
14
  getCachedZenModels,
15
15
  sessionIndexService,
16
16
  sessionIndexSyncRuntime,
17
+ /** Production: committed Host metadata for archive-aware snapshot/lookup. */
18
+ getCommittedHostMetadata = null,
17
19
  transcriptCacheService,
18
20
  express,
19
21
  } = dependencies;
20
22
 
21
- registerSessionIndexRoutes(app, { sessionIndexService, sessionIndexSyncRuntime });
23
+ registerSessionIndexRoutes(app, {
24
+ sessionIndexService,
25
+ sessionIndexSyncRuntime,
26
+ getCommittedHostMetadata,
27
+ });
22
28
  registerTranscriptCacheRoutes(app, { transcriptCacheService });
23
29
  registerConfigSyncRoutes(app, { express });
24
30
 
@@ -1,7 +1,7 @@
1
1
  // Pinned desktop/runtime opencode2. Upgrade and prepare must not fall back to 1.18.x.
2
2
  export const PINNED_OPENCODE2_VERSION = '2.0.12';
3
3
 
4
- // Global npm/bun package that installs the opencode2 binary (not 1.x opencode-ai).
4
+ // Global npm/bun package that installs the opencode v2 binary (not 1.x opencode-ai).
5
5
  export const OPENCODE2_NPM_PACKAGE = '@opencode/cli';
6
6
 
7
7
  export function isOpenCode1xVersion(value) {
@@ -19,8 +19,8 @@ export function isAcceptableOpenCode2HealthVersion(value) {
19
19
  if (typeof value !== 'string') return false;
20
20
  const normalized = value.trim().replace(/^v/i, '');
21
21
  if (!normalized) return false;
22
- if (isOpenCode1xVersion(normalized)) return false;
23
- // Require a version-like token (semver or next pin), not free-form noise.
22
+ // Official CLI is 2.x. 1.x and pre-2 names like `opencode2 v0.0.0-beta-*` are not it.
23
+ if (!/^2(?:\.|$)/.test(normalized)) return false;
24
24
  return /^\d+(?:\.\d+)*(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/.test(normalized);
25
25
  }
26
26
 
@@ -85,7 +85,7 @@ export function openCodeBinaryName(platform = process.platform) {
85
85
 
86
86
  /** @deprecated Packaging leftover. Runtime install uses `openCodeBinaryName`. */
87
87
  export function openCode2BinaryName(platform = process.platform) {
88
- return platform === 'win32' ? 'opencode2.exe' : 'opencode2';
88
+ return openCodeBinaryName(platform);
89
89
  }
90
90
 
91
91
  export function resolveOpenCode2NpmArchitecture(arch = process.arch) {
@@ -43,6 +43,7 @@ describe('opencode2 pin (ticket 12)', () => {
43
43
  it('accepts authoritative v2 health/info versions and rejects 1.x / missing / noise', () => {
44
44
  expect(isAcceptableOpenCode2HealthVersion(PINNED_OPENCODE2_VERSION)).toBe(true);
45
45
  expect(isAcceptableOpenCode2HealthVersion('v2.0.12')).toBe(true);
46
+ expect(isAcceptableOpenCode2HealthVersion('0.0.0-beta-18155')).toBe(false);
46
47
  expect(isAcceptableOpenCode2HealthVersion('1.15.0')).toBe(false);
47
48
  expect(isAcceptableOpenCode2HealthVersion('v1.18.18')).toBe(false);
48
49
  expect(isAcceptableOpenCode2HealthVersion('')).toBe(false);
@@ -1,10 +1,18 @@
1
1
  import { afterEach, describe, expect, it } from 'bun:test';
2
+ import fs from 'node:fs/promises';
2
3
  import net from 'node:net';
4
+ import os from 'node:os';
3
5
  import path from 'node:path';
4
6
 
7
+ import { readOpenCode2BinaryVersion } from './ensure-cli.js';
5
8
  import { createOpenCodeEnvRuntime } from './env-runtime.js';
6
9
  import { createOpenCodeLifecycleRuntime } from './lifecycle.js';
7
10
  import { createOpenCodeNetworkRuntime } from './network-runtime.js';
11
+ import {
12
+ evaluateOpenCodeHealthBody,
13
+ isAcceptableOpenCode2HealthVersion,
14
+ isOpenCode1xVersion,
15
+ } from './opencode2-pin.js';
8
16
  import { fetchV1MigrationGate } from './v1-migration-gate.js';
9
17
 
10
18
  const E2E_PASSWORD = 'openchamber-opencode2-e2e';
@@ -12,6 +20,21 @@ const basicAuth = `Basic ${Buffer.from(`opencode:${E2E_PASSWORD}`, 'utf8').toStr
12
20
  const OPENCODE2_HEALTH_VERSION = /^(?:v)?(?:0\.0\.0-(?:next|beta)(?:-\d+)?|2(?:\.\d+)*)/i;
13
21
  const OPENCODE_1X_VERSION = /^v?1(?:\.|$)/;
14
22
 
23
+ /** Env keys that must not leak into the real user OpenCode install during e2e. */
24
+ const ISOLATION_ENV_KEYS = [
25
+ 'HOME',
26
+ 'USERPROFILE',
27
+ 'XDG_DATA_HOME',
28
+ 'XDG_CONFIG_HOME',
29
+ 'XDG_CACHE_HOME',
30
+ 'XDG_STATE_HOME',
31
+ 'OPENCODE_CONFIG',
32
+ 'OPENCODE_CONFIG_DIR',
33
+ 'OPENCODE_CONFIG_CONTENT',
34
+ 'OPENCHAMBER_DATA_DIR',
35
+ 'OPENCHAMBER_MANAGED_PROCESS_REGISTRY',
36
+ ];
37
+
15
38
  const freePort = () => new Promise((resolve, reject) => {
16
39
  const server = net.createServer();
17
40
  server.once('error', reject);
@@ -46,10 +69,72 @@ const jsonPost = async (url, pathname, payload) => {
46
69
  return { status: response.status, body };
47
70
  };
48
71
 
72
+ const snapshotEnv = (keys) => {
73
+ /** @type {Record<string, string | undefined>} */
74
+ const snap = {};
75
+ for (const key of keys) snap[key] = process.env[key];
76
+ return snap;
77
+ };
78
+
79
+ const restoreEnv = (snap) => {
80
+ for (const [key, value] of Object.entries(snap)) {
81
+ if (typeof value === 'string') process.env[key] = value;
82
+ else delete process.env[key];
83
+ }
84
+ };
85
+
86
+ /**
87
+ * Point HOME + XDG + OpenChamber data at a fresh temp tree so managed opencode2
88
+ * never reads/writes the developer's real ~/.config/opencode or
89
+ * ~/.local/share/opencode (migration/auth/session DB).
90
+ * Does not seed from the user install — empty isolation only.
91
+ */
92
+ const installIsolatedOpenCodeHome = async () => {
93
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), 'openchamber-opencode2-e2e-'));
94
+ const home = path.join(root, 'home');
95
+ const xdgData = path.join(root, 'xdg-data');
96
+ const xdgConfig = path.join(root, 'xdg-config');
97
+ const xdgCache = path.join(root, 'xdg-cache');
98
+ const xdgState = path.join(root, 'xdg-state');
99
+ const openchamberData = path.join(root, 'openchamber-data');
100
+ const opencodeConfig = path.join(xdgConfig, 'opencode');
101
+ await Promise.all([
102
+ fs.mkdir(home, { recursive: true }),
103
+ fs.mkdir(xdgData, { recursive: true }),
104
+ fs.mkdir(xdgConfig, { recursive: true }),
105
+ fs.mkdir(xdgCache, { recursive: true }),
106
+ fs.mkdir(xdgState, { recursive: true }),
107
+ fs.mkdir(openchamberData, { recursive: true }),
108
+ fs.mkdir(opencodeConfig, { recursive: true }),
109
+ ]);
110
+
111
+ process.env.HOME = home;
112
+ if (process.platform === 'win32') process.env.USERPROFILE = home;
113
+ process.env.XDG_DATA_HOME = xdgData;
114
+ process.env.XDG_CONFIG_HOME = xdgConfig;
115
+ process.env.XDG_CACHE_HOME = xdgCache;
116
+ process.env.XDG_STATE_HOME = xdgState;
117
+ process.env.OPENCODE_CONFIG_DIR = opencodeConfig;
118
+ delete process.env.OPENCODE_CONFIG;
119
+ delete process.env.OPENCODE_CONFIG_CONTENT;
120
+ process.env.OPENCHAMBER_DATA_DIR = openchamberData;
121
+ process.env.OPENCHAMBER_MANAGED_PROCESS_REGISTRY = path.join(openchamberData, 'managed-opencode');
122
+
123
+ return root;
124
+ };
125
+
49
126
  describe('opencode2 sidecar e2e', () => {
50
127
  let server = null;
128
+ let isolationRoot = null;
51
129
  const previousBinary = process.env.OPENCODE_BINARY;
52
130
  const previousPassword = process.env.OPENCODE_PASSWORD;
131
+ const previousIsolation = snapshotEnv(ISOLATION_ENV_KEYS);
132
+ // Captured at suite load (before installIsolatedOpenCodeHome mutates HOME).
133
+ const realUserHome = path.resolve(
134
+ previousIsolation.HOME
135
+ || previousIsolation.USERPROFILE
136
+ || os.homedir(),
137
+ );
53
138
 
54
139
  afterEach(async () => {
55
140
  if (server?.close) {
@@ -66,9 +151,16 @@ describe('opencode2 sidecar e2e', () => {
66
151
  } else {
67
152
  delete process.env.OPENCODE_PASSWORD;
68
153
  }
154
+ restoreEnv(previousIsolation);
155
+ if (isolationRoot) {
156
+ await fs.rm(isolationRoot, { recursive: true, force: true }).catch(() => {});
157
+ isolationRoot = null;
158
+ }
69
159
  });
70
160
 
71
161
  it('starts managed opencode2 and serves health, migration, session, and message projection', async () => {
162
+ // Resolve the v2 binary against the real machine first (PATH + home fallbacks).
163
+ // Isolation mutates HOME/XDG and would hide ~/.bun/bin and similar fallbacks.
72
164
  delete process.env.OPENCODE_BINARY;
73
165
  delete process.env.OPENCODE_PATH;
74
166
  delete process.env.OPENCHAMBER_OPENCODE_PATH;
@@ -93,7 +185,16 @@ describe('opencode2 sidecar e2e', () => {
93
185
 
94
186
  const resolved = envRuntime.resolveOpencodeCliPath();
95
187
  expect(resolved).toBeTruthy();
96
- expect(path.basename(resolved).replace(/\.exe$/i, '')).toBe('opencode2');
188
+ // Official v2 may install as `opencode` or legacy alias `opencode2`.
189
+ // Contract is version admission, not basename.
190
+ const binaryVersion = readOpenCode2BinaryVersion(resolved);
191
+ expect(isAcceptableOpenCode2HealthVersion(binaryVersion)).toBe(true);
192
+ expect(isOpenCode1xVersion(binaryVersion)).toBe(false);
193
+
194
+ // Isolate HOME/XDG/OpenChamber data BEFORE spawn so migration/auth never
195
+ // touch the developer's real OpenCode store. Absolute OPENCODE_BINARY keeps
196
+ // the resolved CLI after HOME changes.
197
+ isolationRoot = await installIsolatedOpenCodeHome();
97
198
 
98
199
  process.env.OPENCODE_BINARY = resolved;
99
200
  process.env.OPENCODE_PASSWORD = E2E_PASSWORD;
@@ -160,6 +261,15 @@ describe('opencode2 sidecar e2e', () => {
160
261
  buildManagedOpenCodePath: () => process.env.PATH,
161
262
  getManagedOpenCodeShellEnvSnapshot: () => ({
162
263
  PATH: process.env.PATH,
264
+ HOME: process.env.HOME,
265
+ USERPROFILE: process.env.USERPROFILE,
266
+ XDG_DATA_HOME: process.env.XDG_DATA_HOME,
267
+ XDG_CONFIG_HOME: process.env.XDG_CONFIG_HOME,
268
+ XDG_CACHE_HOME: process.env.XDG_CACHE_HOME,
269
+ XDG_STATE_HOME: process.env.XDG_STATE_HOME,
270
+ OPENCODE_CONFIG_DIR: process.env.OPENCODE_CONFIG_DIR,
271
+ OPENCHAMBER_DATA_DIR: process.env.OPENCHAMBER_DATA_DIR,
272
+ OPENCHAMBER_MANAGED_PROCESS_REGISTRY: process.env.OPENCHAMBER_MANAGED_PROCESS_REGISTRY,
163
273
  OPENCODE_PASSWORD: E2E_PASSWORD,
164
274
  OPENCODE_SERVER_PASSWORD: E2E_PASSWORD,
165
275
  }),
@@ -167,15 +277,20 @@ describe('opencode2 sidecar e2e', () => {
167
277
 
168
278
  server = await runtime.startOpenCode();
169
279
  expect(server.url).toMatch(/^http:\/\/127\.0\.0\.1:\d+$/);
170
- expect(String(state.lastOpenCodeLaunchDiagnostics?.binary || '')).toContain('opencode2');
280
+ const launchedBinary = String(state.lastOpenCodeLaunchDiagnostics?.binary || '');
281
+ expect(launchedBinary.length).toBeGreaterThan(0);
282
+ // Launch path must still resolve to the same admitted v2 binary (name may be opencode).
283
+ expect(isAcceptableOpenCode2HealthVersion(readOpenCode2BinaryVersion(launchedBinary))).toBe(true);
171
284
 
172
285
  const health = await jsonGet(server.url, '/api/info');
173
286
  expect(health.status).toBe(200);
287
+ const healthGate = evaluateOpenCodeHealthBody(health.body);
288
+ expect(healthGate.ok).toBe(true);
289
+ expect(healthGate.version).toMatch(OPENCODE2_HEALTH_VERSION);
290
+ expect(String(health.body?.version ?? '')).not.toMatch(OPENCODE_1X_VERSION);
174
291
  expect(health.body).toMatchObject({
175
- healthy: true,
176
292
  version: expect.stringMatching(OPENCODE2_HEALTH_VERSION),
177
293
  });
178
- expect(String(health.body?.version ?? '')).not.toMatch(OPENCODE_1X_VERSION);
179
294
 
180
295
  const unauthenticated = await fetch(`${server.url}/api/info`);
181
296
  expect(unauthenticated.status).toBe(401);
@@ -187,6 +302,14 @@ describe('opencode2 sidecar e2e', () => {
187
302
  expect(gate.admitTranscript).toBe(true);
188
303
  expect(['completed', 'absent']).toContain(gate.phase);
189
304
 
305
+ // Safety: isolation root must stay under tmp and never equal real user home.
306
+ expect(isolationRoot.startsWith(os.tmpdir())).toBe(true);
307
+ expect(path.resolve(process.env.HOME)).not.toBe(path.resolve(realUserHome));
308
+ // Real user OpenCode paths must remain untouched (no accidental migration write).
309
+ const realConfigMarker = path.join(realUserHome, '.config', 'opencode');
310
+ const isolatedConfig = process.env.OPENCODE_CONFIG_DIR;
311
+ expect(path.resolve(isolatedConfig)).not.toBe(path.resolve(realConfigMarker));
312
+
190
313
  const created = await jsonPost(server.url, '/api/session', {});
191
314
  expect(created.status).toBe(200);
192
315
  const sessionID = created.body?.data?.id ?? created.body?.id;