@vantran-se/openchamber-web 1.24.4 → 2.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (367) hide show
  1. package/README.md +2 -8
  2. package/bin/lib/commands-connect-url.js +3 -8
  3. package/bin/lib/commands-tunnel.js +3 -0
  4. package/dist/assets/{CodeMirrorCommentWidgets-B4a0JkUz.js → CodeMirrorCommentWidgets-ntXIbhV2.js} +1 -1
  5. package/dist/assets/DiffView-CjxUovbM.js +4 -0
  6. package/dist/assets/FilesView-DFWD0RQR.js +17 -0
  7. package/dist/assets/{GitView-Byu2DY7s.js → GitView-BWa7e5vw.js} +4 -4
  8. package/dist/assets/{GuestAttachDialog-B808E0-d.js → GuestAttachDialog-DdOuyZzp.js} +1 -1
  9. package/dist/assets/GuestSurfacePane-DDfzid3U.js +1 -0
  10. package/dist/assets/{JsonTreeView-VW_7czo3.js → JsonTreeView-3sGwqexh.js} +1 -1
  11. package/dist/assets/LinearIssuesView-C6dpIdjw.js +1 -0
  12. package/dist/assets/MarkdownImageGallery-Q7YR_tl4.js +1 -0
  13. package/dist/assets/MarkdownRendererImpl-DxDV56bL.js +12 -0
  14. package/dist/assets/OnboardingScreen-rK4Stg__.js +1 -0
  15. package/dist/assets/{PatchDiff-DCjGhlYV.js → PatchDiff-DS-IMU1j.js} +1 -1
  16. package/dist/assets/{PierreDiffViewer-CEDZNkU2.js → PierreDiffViewer-DW23KWKZ.js} +1 -1
  17. package/dist/assets/PlanView-BCk_VobP.js +5 -0
  18. package/dist/assets/PluginPane-BPP1dbck.js +3 -0
  19. package/dist/assets/SettingsView-kMmrpn43.js +29 -0
  20. package/dist/assets/SettingsWindow-BpvD6IYQ.js +1 -0
  21. package/dist/assets/SubmoduleDiffSummary-BLttSS1X.js +1 -0
  22. package/dist/assets/ToolOutputDialog-CtqDqbyr.js +15 -0
  23. package/dist/assets/ToolPartDiffPreview-5FcKdyRy.js +8 -0
  24. package/dist/assets/{VirtualizedFileDiff-NU0FHFhT.js → VirtualizedFileDiff-C763hNCR.js} +4 -4
  25. package/dist/assets/{WalkthroughView-BZyjhqHv.js → WalkthroughView-lap5btCF.js} +2 -2
  26. package/dist/assets/appThemeRegistry-u4zw1l0B.js +1 -0
  27. package/dist/assets/{commitAndPush-BDftrjr6.js → commitAndPush-CwGJfJG-.js} +1 -1
  28. package/dist/assets/{computeVirtualFileMetrics-DlWlEM3x.js → computeVirtualFileMetrics-DW5jjX4n.js} +1 -1
  29. package/dist/assets/de-CndEg67T.js +10 -0
  30. package/dist/assets/es-DglvPlaC.js +16 -0
  31. package/dist/assets/fr-DejPCpbS.js +14 -0
  32. package/dist/assets/index-C2r64QfD.js +1 -0
  33. package/dist/assets/index-CLcM8HKx.css +1 -0
  34. package/dist/assets/index-CvLgnuNI.js +41 -0
  35. package/dist/assets/{isDiffPlainText-CO4e5J_S.js → isDiffPlainText-D47a6hMP.js} +1 -1
  36. package/dist/assets/{isFilePlainText-ue6kCou7.js → isFilePlainText-B-mGgeKl.js} +1 -1
  37. package/dist/assets/ja-CVpQawCd.js +16 -0
  38. package/dist/assets/ko-Bw446h9j.js +16 -0
  39. package/dist/assets/main-Bz4WWXbr.js +2 -0
  40. package/dist/assets/main-oreqDsOV.js +896 -0
  41. package/dist/assets/{markdownCore-CK48LG0z.js → markdownCore-D2e5sp9y.js} +4 -4
  42. package/dist/assets/miniChat-D1v2gsBI.js +2 -0
  43. package/dist/assets/mobile-DBj3lCf2.js +2 -0
  44. package/dist/assets/{patchFileDiff-D7QVlovD.js → patchFileDiff-BpeUOV91.js} +3 -3
  45. package/dist/assets/pl-BYNlPdhm.js +16 -0
  46. package/dist/assets/pt-BR-CL-Yfv9H.js +16 -0
  47. package/dist/assets/renderElectronMiniChatApp-DVtfEB2z.js +2 -0
  48. package/dist/assets/renderMobileApp-DFp9-xbG.js +25 -0
  49. package/dist/assets/runtimeEndpointReset-CsIpUep1.js +27 -0
  50. package/dist/assets/scheduledTasksApi-B5OHVpPM.js +1 -0
  51. package/dist/assets/{shikiHighlight-BPu3JiQs.js → shikiHighlight-DNm2Z_Jc.js} +1 -1
  52. package/dist/assets/{textMateThemeFromAppTheme-DWqRPo9A.js → textMateThemeFromAppTheme-DGF2kG7g.js} +1 -1
  53. package/dist/assets/tr-DzmbxLy-.js +7 -0
  54. package/dist/assets/uk-C7vQnwro.js +16 -0
  55. package/dist/assets/useAppFontEffects-Ci3FEaPf.js +7337 -0
  56. package/dist/assets/{useInlineCommentController-CgS0fnw_.js → useInlineCommentController-CuUuakrt.js} +2 -2
  57. package/dist/assets/useRootScrollLock-CQIsyERq.js +2 -0
  58. package/dist/assets/{vendor-codemirror-language-data-Bx2EHc1O.js → vendor-codemirror-language-data-CQa7_1cr.js} +2 -2
  59. package/dist/assets/vendor-cron-parser-CjvYVigk.js +2 -0
  60. package/dist/assets/vendor-opencode-client-_BAHQo4R.js +12 -0
  61. package/dist/assets/{vendor-remixicon-react-BDmGmFCA.js → vendor-remixicon-react-D6dZ0UwF.js} +1 -1
  62. package/dist/assets/{vendor-zod-CgAdXWZO.js → vendor-zod-Gea9uBlY.js} +1 -1
  63. package/dist/assets/{web-components-DxX8bZ1o.js → web-components-B6tKQyJN.js} +2 -2
  64. package/dist/assets/zh-CN-Cl0srS-z.js +16 -0
  65. package/dist/assets/zh-TW-DtII5bDb.js +16 -0
  66. package/dist/index.html +13 -7
  67. package/dist/mini-chat.html +5 -5
  68. package/dist/mobile.html +5 -5
  69. package/package.json +6 -5
  70. package/server/index.d.ts +1 -0
  71. package/server/index.js +246 -91
  72. package/server/lib/agent-tool/DOCUMENTATION.md +32 -15
  73. package/server/lib/agent-tool/runtime.js +135 -54
  74. package/server/lib/agent-tool/runtime.test.js +155 -87
  75. package/server/lib/browser-control/DOCUMENTATION.md +37 -2
  76. package/server/lib/browser-control/provider.js +217 -0
  77. package/server/lib/browser-control/provider.test.js +251 -0
  78. package/server/lib/context-obligatory/runtime.js +61 -50
  79. package/server/lib/context-obligatory/runtime.test.js +62 -162
  80. package/server/lib/event-stream/DOCUMENTATION.md +12 -1
  81. package/server/lib/event-stream/delta-coalescer.js +0 -0
  82. package/server/lib/event-stream/delta-coalescer.test.js +68 -57
  83. package/server/lib/event-stream/directory-ws-bridge.js +1 -1
  84. package/server/lib/event-stream/global-hub.js +10 -1
  85. package/server/lib/event-stream/global-hub.test.js +27 -26
  86. package/server/lib/event-stream/index.js +6 -0
  87. package/server/lib/event-stream/protocol.js +26 -19
  88. package/server/lib/event-stream/protocol.test.js +18 -7
  89. package/server/lib/event-stream/rebind.test.js +5 -5
  90. package/server/lib/event-stream/runtime.test.js +17 -15
  91. package/server/lib/event-stream/translate-v2.js +313 -0
  92. package/server/lib/event-stream/translate-v2.test.js +209 -0
  93. package/server/lib/event-stream/upstream-reader.test.js +16 -16
  94. package/server/lib/fs/DOCUMENTATION.md +4 -2
  95. package/server/lib/fs/byte-range.js +35 -0
  96. package/server/lib/fs/byte-range.test.js +31 -0
  97. package/server/lib/fs/routes.js +47 -42
  98. package/server/lib/fs/routes.test.js +123 -18
  99. package/server/lib/git/service.js +12 -0
  100. package/server/lib/github/DOCUMENTATION.md +1 -1
  101. package/server/lib/github/pr-status.js +22 -2
  102. package/server/lib/github/pr-status.test.js +27 -14
  103. package/server/lib/guests/DOCUMENTATION.md +13 -0
  104. package/server/lib/guests/catalog.js +12 -0
  105. package/server/lib/guests/catalog.test.js +33 -0
  106. package/server/lib/guests/routes.js +10 -1
  107. package/server/lib/guests/service.js +192 -14
  108. package/server/lib/guests/service.test.js +201 -1
  109. package/server/lib/guests/surface.js +630 -0
  110. package/server/lib/guests/surface.test.js +486 -0
  111. package/server/lib/markdown-image-grants/routes.js +18 -5
  112. package/server/lib/markdown-image-grants/routes.test.js +9 -6
  113. package/server/lib/message-queue/DOCUMENTATION.md +33 -22
  114. package/server/lib/message-queue/runtime.js +155 -100
  115. package/server/lib/message-queue/runtime.test.js +168 -82
  116. package/server/lib/notifications/DOCUMENTATION.md +28 -0
  117. package/server/lib/notifications/control-stream.http.test.js +68 -0
  118. package/server/lib/notifications/emitter-runtime.js +15 -0
  119. package/server/lib/notifications/emitter-runtime.test.js +32 -0
  120. package/server/lib/notifications/push-runtime.js +5 -1
  121. package/server/lib/notifications/push-runtime.test.js +2 -2
  122. package/server/lib/notifications/routes.js +1 -1
  123. package/server/lib/notifications/runtime.js +115 -26
  124. package/server/lib/notifications/template-runtime.js +13 -15
  125. package/server/lib/notifications/template-runtime.test.js +16 -9
  126. package/server/lib/notifications/trigger-runtime.test.js +187 -0
  127. package/server/lib/openchamber-control/DOCUMENTATION.md +10 -0
  128. package/server/lib/openchamber-control/actions.js +3 -2
  129. package/server/lib/openchamber-control/error.js +1 -1
  130. package/server/lib/openchamber-control/file-open.js +59 -0
  131. package/server/lib/openchamber-control/file-open.test.js +57 -0
  132. package/server/lib/openchamber-control/routes.test.js +12 -0
  133. package/server/lib/openchamber-control/service.js +102 -49
  134. package/server/lib/openchamber-control/service.test.js +84 -44
  135. package/server/lib/openchamber-sessions/archive-store.js +224 -0
  136. package/server/lib/openchamber-sessions/archive-store.test.js +187 -0
  137. package/server/lib/openchamber-sessions/opencode-client.js +22 -0
  138. package/server/lib/openchamber-sessions/routes.js +398 -328
  139. package/server/lib/openchamber-sessions/routes.test.js +777 -740
  140. package/server/lib/openchamber-sessions/session-metadata-store.js +285 -0
  141. package/server/lib/openchamber-sessions/session-metadata-store.test.js +259 -0
  142. package/server/lib/opencode/DOCUMENTATION.md +449 -29
  143. package/server/lib/opencode/agents.js +233 -327
  144. package/server/lib/opencode/auth.js +41 -44
  145. package/server/lib/opencode/auth.test.js +92 -0
  146. package/server/lib/opencode/cli-upgrade.d.ts +4 -0
  147. package/server/lib/opencode/cli-upgrade.js +18 -0
  148. package/server/lib/opencode/cli-upgrade.test.js +36 -0
  149. package/server/lib/opencode/commands.js +164 -145
  150. package/server/lib/opencode/compatibility.d.ts +16 -0
  151. package/server/lib/opencode/compatibility.js +88 -0
  152. package/server/lib/opencode/compatibility.test.js +62 -0
  153. package/server/lib/opencode/config-entity-routes.js +58 -29
  154. package/server/lib/opencode/config-entity-v2.test.js +290 -0
  155. package/server/lib/opencode/config-mutation-response.js +17 -7
  156. package/server/lib/opencode/config-mutation-response.test.js +4 -7
  157. package/server/lib/opencode/config-paths.test.js +2 -2
  158. package/server/lib/opencode/config-v2.d.ts +183 -0
  159. package/server/lib/opencode/config-v2.js +801 -0
  160. package/server/lib/opencode/config-v2.test.js +466 -0
  161. package/server/lib/opencode/credential-db.js +144 -0
  162. package/server/lib/opencode/credential-db.test.js +104 -0
  163. package/server/lib/opencode/env-runtime.js +48 -9
  164. package/server/lib/opencode/env-runtime.test.js +45 -0
  165. package/server/lib/opencode/feature-routes-runtime.js +15 -5
  166. package/server/lib/opencode/lifecycle-process.test.js +7 -4
  167. package/server/lib/opencode/lifecycle.js +85 -30
  168. package/server/lib/opencode/lifecycle.test.js +80 -8
  169. package/server/lib/opencode/login-shell-env.js +27 -0
  170. package/server/lib/opencode/managed-config-file.js +119 -0
  171. package/server/lib/opencode/managed-config-file.test.js +152 -0
  172. package/server/lib/opencode/managed-plugin-config.js +33 -13
  173. package/server/lib/opencode/mcp.js +43 -149
  174. package/server/lib/opencode/network-runtime.js +6 -7
  175. package/server/lib/opencode/openchamber-routes.test.js +2 -5
  176. package/server/lib/opencode/plugin-routes.js +3 -3
  177. package/server/lib/opencode/plugin-routes.test.js +6 -24
  178. package/server/lib/opencode/plugins.js +91 -40
  179. package/server/lib/opencode/plugins.test.js +28 -15
  180. package/server/lib/opencode/providers.js +63 -83
  181. package/server/lib/opencode/providers.test.js +128 -59
  182. package/server/lib/opencode/proxy-agent-wiring.test.js +4 -2
  183. package/server/lib/opencode/proxy.js +179 -129
  184. package/server/lib/opencode/pwa-manifest-routes.js +4 -2
  185. package/server/lib/opencode/pwa-manifest-routes.test.js +3 -2
  186. package/server/lib/opencode/response-envelope.js +14 -0
  187. package/server/lib/opencode/response-envelope.test.js +23 -0
  188. package/server/lib/opencode/routes-upgrade.test.js +73 -116
  189. package/server/lib/opencode/routes.js +121 -428
  190. package/server/lib/opencode/server-utils-runtime.js +10 -4
  191. package/server/lib/opencode/session-activity.js +78 -0
  192. package/server/lib/opencode/session-runtime.js +21 -10
  193. package/server/lib/opencode/session-runtime.test.js +9 -9
  194. package/server/lib/opencode/settings-helpers.js +9 -9
  195. package/server/lib/opencode/settings-helpers.test.js +7 -12
  196. package/server/lib/opencode/settings-registry.json +6 -8
  197. package/server/lib/opencode/settings-runtime.js +18 -1
  198. package/server/lib/opencode/settings-runtime.test.js +31 -2
  199. package/server/lib/opencode/shared.js +54 -22
  200. package/server/lib/opencode/shared.test.js +8 -4
  201. package/server/lib/opencode/shutdown-runtime.js +54 -7
  202. package/server/lib/opencode/shutdown-runtime.test.js +224 -30
  203. package/server/lib/opencode/skill-routes.js +23 -24
  204. package/server/lib/opencode/skills.js +7 -5
  205. package/server/lib/opencode/tunnel-wiring-runtime.js +2 -1
  206. package/server/lib/opencode/upgrade-capability.test.js +1 -1
  207. package/server/lib/opencode/v1-migration-topup.js +288 -0
  208. package/server/lib/opencode/v1-migration-topup.test.js +266 -0
  209. package/server/lib/opencode/v2-install.d.ts +8 -0
  210. package/server/lib/opencode/v2-install.js +158 -0
  211. package/server/lib/opencode/v2-install.test.js +124 -0
  212. package/server/lib/opencode/watcher.js +10 -3
  213. package/server/lib/opencode/watcher.test.js +17 -21
  214. package/server/lib/opencode/websearch-config.js +66 -0
  215. package/server/lib/package-manager.js +55 -68
  216. package/server/lib/package-manager.test.js +241 -75
  217. package/server/lib/permission-auto-accept/runtime.js +38 -22
  218. package/server/lib/permission-auto-accept/runtime.test.js +39 -17
  219. package/server/lib/quota/DOCUMENTATION.md +4 -0
  220. package/server/lib/quota/providers/xai.js +4 -4
  221. package/server/lib/relay/tunnel-host.js +4 -1
  222. package/server/lib/relay/tunnel-host.test.js +7 -0
  223. package/server/lib/routing/DOCUMENTATION.md +52 -21
  224. package/server/lib/routing/defaults.js +23 -6
  225. package/server/lib/routing/jev.js +17 -7
  226. package/server/lib/routing/routes.http.test.js +68 -33
  227. package/server/lib/routing/routes.js +65 -22
  228. package/server/lib/routing/runtime.js +145 -48
  229. package/server/lib/routing/runtime.test.js +79 -55
  230. package/server/lib/scheduled-tasks/DOCUMENTATION.md +4 -0
  231. package/server/lib/scheduled-tasks/issue-2710-double-execution.test.js +51 -46
  232. package/server/lib/scheduled-tasks/runtime.js +81 -74
  233. package/server/lib/scheduled-tasks/runtime.test.js +53 -1
  234. package/server/lib/session-assist/DOCUMENTATION.md +8 -3
  235. package/server/lib/session-assist/context.js +122 -46
  236. package/server/lib/session-assist/context.test.js +117 -49
  237. package/server/lib/session-assist/runtime.js +52 -22
  238. package/server/lib/session-assist/runtime.test.js +145 -173
  239. package/server/lib/session-goal/DOCUMENTATION.md +23 -15
  240. package/server/lib/session-goal/create.js +9 -12
  241. package/server/lib/session-goal/create.test.js +27 -28
  242. package/server/lib/session-goal/runtime.js +164 -77
  243. package/server/lib/session-goal/runtime.test.js +271 -519
  244. package/server/lib/session-knowledge/DOCUMENTATION.md +5 -0
  245. package/server/lib/session-knowledge/runtime.js +59 -41
  246. package/server/lib/session-knowledge/runtime.test.js +90 -20
  247. package/server/lib/small-model/DOCUMENTATION.md +154 -208
  248. package/server/lib/small-model/client.js +113 -0
  249. package/server/lib/small-model/index.js +296 -158
  250. package/server/lib/small-model/index.test.js +423 -256
  251. package/server/lib/small-model/routes.js +1 -1
  252. package/server/lib/spaces/DOCUMENTATION.md +807 -0
  253. package/server/lib/spaces/code-in-bait.js +185 -0
  254. package/server/lib/spaces/code-in.js +641 -0
  255. package/server/lib/spaces/code-in.test.js +1048 -0
  256. package/server/lib/spaces/errors.js +8 -0
  257. package/server/lib/spaces/exec-http.js +94 -0
  258. package/server/lib/spaces/gatekeeper-channel.js +202 -0
  259. package/server/lib/spaces/gatekeeper-channel.test.js +215 -0
  260. package/server/lib/spaces/gatekeeper-program.cjs +914 -0
  261. package/server/lib/spaces/gatekeeper-program.test.js +1391 -0
  262. package/server/lib/spaces/hardening.js +457 -0
  263. package/server/lib/spaces/hardening.test.js +368 -0
  264. package/server/lib/spaces/host-git.js +127 -0
  265. package/server/lib/spaces/host-git.test.js +137 -0
  266. package/server/lib/spaces/labels.js +234 -0
  267. package/server/lib/spaces/labels.test.js +219 -0
  268. package/server/lib/spaces/layout.js +184 -0
  269. package/server/lib/spaces/layout.test.js +30 -0
  270. package/server/lib/spaces/manager.js +82 -0
  271. package/server/lib/spaces/manager.test.js +114 -0
  272. package/server/lib/spaces/places/code-in.docker.live.test.js +216 -0
  273. package/server/lib/spaces/places/contract-suite.js +156 -0
  274. package/server/lib/spaces/places/contract.docker.live.test.js +7 -0
  275. package/server/lib/spaces/places/contract.test.js +6 -0
  276. package/server/lib/spaces/places/docker-engine.js +126 -0
  277. package/server/lib/spaces/places/docker-live-support.js +202 -0
  278. package/server/lib/spaces/places/docker-tools.js +186 -0
  279. package/server/lib/spaces/places/docker.js +587 -0
  280. package/server/lib/spaces/places/docker.test.js +1610 -0
  281. package/server/lib/spaces/places/escape-suite.js +1018 -0
  282. package/server/lib/spaces/places/escape.docker.live.test.js +7 -0
  283. package/server/lib/spaces/places/fake-docker.js +375 -0
  284. package/server/lib/spaces/places/memory-place.js +103 -0
  285. package/server/lib/spaces/places/packed.docker.live.test.js +77 -0
  286. package/server/lib/spaces/places/registry.js +43 -0
  287. package/server/lib/spaces/places/registry.test.js +50 -0
  288. package/server/lib/spaces/places/server.docker.live.test.js +225 -0
  289. package/server/lib/spaces/run-command.js +261 -0
  290. package/server/lib/spaces/run-command.test.js +396 -0
  291. package/server/lib/spaces/space-server.js +190 -0
  292. package/server/lib/spaces/space-server.test.js +335 -0
  293. package/server/lib/spaces/tools-filler.js +82 -0
  294. package/server/lib/spaces/tools-filler.test.js +148 -0
  295. package/server/lib/spaces/tools-pack.js +49 -0
  296. package/server/lib/spaces/tools-pack.test.js +57 -0
  297. package/server/lib/spaces/tools.js +138 -0
  298. package/server/lib/spaces/tools.test.js +153 -0
  299. package/server/lib/tts/openai-sdk.js +11 -0
  300. package/server/lib/tts/service.js +7 -5
  301. package/server/lib/tts/stt.js +3 -2
  302. package/server/lib/tunnels/DOCUMENTATION.md +2 -0
  303. package/server/lib/tunnels/routes.js +7 -0
  304. package/server/lib/tunnels/routes.test.js +55 -0
  305. package/server/lib/ui-auth/ui-auth.js +38 -2
  306. package/server/lib/ui-auth/ui-auth.test.js +42 -0
  307. package/server/lib/ui-auth/ui-passkeys.js +12 -10
  308. package/server/lib/walkthrough/DOCUMENTATION.md +10 -1
  309. package/server/lib/walkthrough/pull-request.js +61 -8
  310. package/server/lib/walkthrough/pull-request.test.js +54 -1
  311. package/server/lib/walkthrough/routes.js +20 -0
  312. package/server/lib/walkthrough/routes.test.js +19 -0
  313. package/server/opencode-proxy.test.js +190 -166
  314. package/server/opencode-worktree-readiness.test.js +5 -4
  315. package/dist/assets/DiffView-B2CD9wC8.js +0 -4
  316. package/dist/assets/FilesView-C4vMFclc.js +0 -13
  317. package/dist/assets/LinearIssuesView-EzGNCGrd.js +0 -1
  318. package/dist/assets/MarkdownImageGallery-B2YUDFaB.js +0 -1
  319. package/dist/assets/MarkdownRendererImpl-B4ikIzR3.js +0 -12
  320. package/dist/assets/OnboardingScreen-CQHBSTEi.js +0 -1
  321. package/dist/assets/PlanView-D3x-pTkZ.js +0 -5
  322. package/dist/assets/PluginPane-DEcgDQ9h.js +0 -3
  323. package/dist/assets/SettingsView-CSinuH-1.js +0 -29
  324. package/dist/assets/SettingsWindow-B5CHoJFZ.js +0 -1
  325. package/dist/assets/SubmoduleDiffSummary-oxeACCWs.js +0 -1
  326. package/dist/assets/ToolOutputDialog-tLNWmfcW.js +0 -15
  327. package/dist/assets/ToolPartDiffPreview-CflzmK-x.js +0 -8
  328. package/dist/assets/appThemeRegistry-DLicw4dH.js +0 -1
  329. package/dist/assets/de-Bro6_zyl.js +0 -10
  330. package/dist/assets/es-Bb9XQZvd.js +0 -16
  331. package/dist/assets/fr-Dgfzn-IL.js +0 -14
  332. package/dist/assets/index-CtQNmqUL.js +0 -1
  333. package/dist/assets/index-DqaMWNrC.css +0 -1
  334. package/dist/assets/index-Dqc6USl3.js +0 -40
  335. package/dist/assets/ja-BYsakGFh.js +0 -16
  336. package/dist/assets/ko-BSaQz9aN.js +0 -16
  337. package/dist/assets/main-B62_pjL5.js +0 -2
  338. package/dist/assets/main-BEdcOI2P.js +0 -896
  339. package/dist/assets/miniChat-LT8Rqofr.js +0 -2
  340. package/dist/assets/mobile-CqarEynh.js +0 -2
  341. package/dist/assets/pl-OIviPdoj.js +0 -16
  342. package/dist/assets/pt-BR-Cltg-E6s.js +0 -16
  343. package/dist/assets/renderElectronMiniChatApp-COyLTtZx.js +0 -2
  344. package/dist/assets/renderMobileApp-BC1MHg6g.js +0 -25
  345. package/dist/assets/runtimeEndpointReset-DdrazOVH.js +0 -25
  346. package/dist/assets/scheduledTasksApi-B2SWMGEU.js +0 -1
  347. package/dist/assets/tr-CQI-KPre.js +0 -7
  348. package/dist/assets/uk-CqW7R4DA.js +0 -16
  349. package/dist/assets/useAppFontEffects-ne84JBVL.js +0 -7333
  350. package/dist/assets/useRootScrollLock-Cpf1qoGp.js +0 -2
  351. package/dist/assets/vendor-cron-parser-CUcP7OfX.js +0 -2
  352. package/dist/assets/vendor-opencode-sdk-BJ73u-bH.js +0 -7
  353. package/dist/assets/zh-CN-CVZG_o_J.js +0 -16
  354. package/dist/assets/zh-TW-D5ZxzCZp.js +0 -16
  355. package/server/lib/opencode/mcp-oauth-callback.test.js +0 -111
  356. package/server/lib/routing/feature-flag.js +0 -11
  357. package/server/lib/small-model/call.js +0 -851
  358. package/server/lib/small-model/call.test.js +0 -1292
  359. package/server/lib/small-model/catalog.js +0 -13
  360. package/server/lib/small-model/resolve.js +0 -131
  361. package/server/lib/small-model/resolve.test.js +0 -197
  362. package/server/lib/small-model/runtime-providers.js +0 -164
  363. package/server/lib/small-model/runtime-providers.test.js +0 -118
  364. package/server/lib/system-prompt/DOCUMENTATION.md +0 -36
  365. package/server/lib/system-prompt/runtime.js +0 -49
  366. package/server/lib/system-prompt/runtime.test.js +0 -76
  367. package/server/lib/walkthrough/reproduce-2607.test.js +0 -148
@@ -1,10 +1,17 @@
1
1
  import express from 'express';
2
- import { createOpencodeClient } from '@opencode-ai/sdk/v2';
3
- import { createWorktree, getWorktreeBootstrapStatus, resolvePrimaryWorktreeRoot } from '../git/index.js';
2
+ import {
3
+ createWorktree as createWorktreeDefault,
4
+ getWorktreeBootstrapStatus as getWorktreeBootstrapStatusDefault,
5
+ resolvePrimaryWorktreeRoot,
6
+ } from '../git/index.js';
4
7
  import { expandSnippets } from '../opencode/snippets.js';
5
- import { expandCommandGoalObjective, parseScheduledCommandPrompt } from '../scheduled-tasks/runtime.js';
8
+ import { AUTO_MODEL_REF, isAutoModel } from '../routing/defaults.js';
9
+ import { parseScheduledCommandPrompt } from '../scheduled-tasks/runtime.js';
6
10
  import { buildGoalIntroText, createSessionGoal } from '../session-goal/create.js';
7
11
  import { OpenChamberControlError, asControlError } from '../openchamber-control/error.js';
12
+ import { createArchiveStore } from './archive-store.js';
13
+ import { createOpenCodeClient as defaultCreateOpenCodeClient } from './opencode-client.js';
14
+ import { createSessionMetadataStore, createOpenCodeSessionMetadata } from './session-metadata-store.js';
8
15
 
9
16
  const asNonEmptyString = (value) => {
10
17
  if (typeof value !== 'string') return null;
@@ -12,6 +19,8 @@ const asNonEmptyString = (value) => {
12
19
  return trimmed.length > 0 ? trimmed : null;
13
20
  };
14
21
 
22
+ const asList = (value) => (Array.isArray(value) ? value : []);
23
+
15
24
  const splitModel = (value) => {
16
25
  const model = asNonEmptyString(value);
17
26
  if (!model) return null;
@@ -59,29 +68,33 @@ const resolveGoalInput = (payload, prompt) => {
59
68
 
60
69
  const isPrimaryAgentMode = (mode) => !mode || mode === 'primary' || mode === 'all';
61
70
 
62
- const providerModels = (provider) => {
63
- if (Array.isArray(provider?.models)) return provider.models;
64
- if (provider?.models && typeof provider.models === 'object') return Object.values(provider.models);
65
- return [];
66
- };
71
+ // OpenCode 2.x serves one flat model catalogue instead of models nested under
72
+ // providers: every entry already names its provider.
73
+ const hasCatalogModel = (models, providerID, modelID) => models.some(
74
+ (model) => model?.providerID === providerID && model?.modelID === modelID,
75
+ );
67
76
 
68
- const hasProviderModel = (providers, providerID, modelID) => {
69
- return providers.some((provider) => provider?.id === providerID
70
- && providerModels(provider).some((model) => model?.id === modelID));
71
- };
77
+ const findCatalogModel = (models, providerID, modelID) => models.find(
78
+ (model) => model?.providerID === providerID && model?.modelID === modelID,
79
+ ) || null;
72
80
 
73
- const resolveVariant = (providers, providerID, modelID, variant) => {
81
+ const resolveVariant = (models, providerID, modelID, variant) => {
74
82
  const normalized = asNonEmptyString(variant);
75
83
  if (!normalized) return undefined;
76
- const provider = providers.find((entry) => entry?.id === providerID);
77
- const model = providerModels(provider).find((entry) => entry?.id === modelID);
84
+ const model = findCatalogModel(models, providerID, modelID);
85
+ // A model the catalog does not know yet (cold or unreachable) keeps the
86
+ // user's saved variant instead of losing it to a discovery gap.
78
87
  if (!model) return normalized;
79
- return model?.variants && Object.prototype.hasOwnProperty.call(model.variants, normalized)
80
- ? normalized
81
- : undefined;
88
+ return asList(model.variants).some((entry) => entry?.id === normalized) ? normalized : undefined;
82
89
  };
83
90
 
84
- const parseConfigModel = (value) => splitModel(value);
91
+ // Config `model` is either "providerID/modelID" or the expanded object form.
92
+ const parseConfigModel = (value) => {
93
+ if (typeof value === 'string') return splitModel(value);
94
+ const providerID = asNonEmptyString(value?.providerID);
95
+ const modelID = asNonEmptyString(value?.model);
96
+ return providerID && modelID ? { providerID, modelID } : null;
97
+ };
85
98
 
86
99
  const resolveProjectDefaults = (settings, directory, projectId) => {
87
100
  const projects = Array.isArray(settings?.projects) ? settings.projects : [];
@@ -95,181 +108,161 @@ const resolveProjectDefaults = (settings, directory, projectId) => {
95
108
  };
96
109
  };
97
110
 
98
- const buildDirectoryHeaders = (directory) => ({
99
- // OpenCode rejects non-ASCII header values; the official SDK sends this
100
- // header percent-encoded, so match that wire format (non-ASCII checkout
101
- // paths such as "Masaüstü" otherwise fail every dispatched prompt).
102
- ...(directory ? { 'x-opencode-directory': encodeURIComponent(directory) } : {}),
103
- });
104
-
105
- const fetchJson = async (url, authHeaders, fallback, directory) => {
106
- const response = await fetch(url.toString(), {
107
- headers: { ...authHeaders, ...buildDirectoryHeaders(directory), accept: 'application/json' },
108
- });
109
- if (!response.ok) return fallback;
110
- return response.json().catch(() => fallback);
111
- };
112
-
113
- const fetchSelectionInputs = async ({ buildOpenCodeUrl, authHeaders, directory, readSettingsFromDiskMigrated }) => {
111
+ /** `x-opencode-directory` is how v2 scopes a request; there is no query param. */
112
+ /**
113
+ * Everything the default model/agent resolution needs, from one directory-scoped
114
+ * client. A failed lookup answers empty on purpose: an empty catalogue means
115
+ * "unknown", and callers must never turn that into a rejection.
116
+ */
117
+ const fetchSelectionInputs = async ({ client, readSettingsFromDiskMigrated }) => {
114
118
  const settings = await readSettingsFromDiskMigrated();
115
- const providersUrl = new URL(buildOpenCodeUrl('/config/providers', ''));
116
- providersUrl.searchParams.set('directory', directory);
117
- const agentsUrl = new URL(buildOpenCodeUrl('/agent', ''));
118
- agentsUrl.searchParams.set('directory', directory);
119
- const configUrl = new URL(buildOpenCodeUrl('/config', ''));
120
- configUrl.searchParams.set('directory', directory);
121
-
122
- const [providersBody, agentsBody, configBody] = await Promise.all([
123
- fetchJson(providersUrl, authHeaders, { providers: [] }, directory),
124
- fetchJson(agentsUrl, authHeaders, [], directory),
125
- fetchJson(configUrl, authHeaders, {}, directory),
119
+ const [models, agents, configEntries] = await Promise.all([
120
+ client.model.list().then((response) => asList(response?.data)).catch(() => []),
121
+ // v2 agents carry `id` (`build`, what prompts and sessions refer to) and a
122
+ // display `name` (`Build`); every lookup here is by id.
123
+ client.agent.list().then((response) => asList(response?.data)).catch(() => []),
124
+ client.config.get().then((response) => asList(response)).catch(() => []),
126
125
  ]);
127
126
 
128
- return {
129
- settings,
130
- providers: Array.isArray(providersBody?.providers) ? providersBody.providers : [],
131
- agents: Array.isArray(agentsBody) ? agentsBody : [],
132
- opencodeDefaultAgent: asNonEmptyString(configBody?.default_agent) || asNonEmptyString(configBody?.defaultAgent),
133
- opencodeDefaultModel: asNonEmptyString(configBody?.model),
134
- };
127
+ // Config entries arrive lowest priority first, so the last definition wins.
128
+ let opencodeDefaultAgent = null;
129
+ let opencodeDefaultModel = null;
130
+ for (const entry of configEntries) {
131
+ const info = entry?.info;
132
+ if (!info) continue;
133
+ const agent = asNonEmptyString(info.default_agent);
134
+ if (agent) opencodeDefaultAgent = agent;
135
+ const model = parseConfigModel(info.model);
136
+ if (model) opencodeDefaultModel = model;
137
+ }
138
+
139
+ return { settings, models, agents, opencodeDefaultAgent, opencodeDefaultModel };
135
140
  };
136
141
 
137
- const resolveDefaultSelection = ({ agents, providers, settings, projectDefaults, opencodeDefaultAgent, opencodeDefaultModel }) => {
142
+ const resolveDefaultSelection = ({ agents, models, settings, projectDefaults, opencodeDefaultAgent, opencodeDefaultModel }) => {
138
143
  const primaryAgents = agents.filter((agent) => isPrimaryAgentMode(agent?.mode) && agent?.hidden !== true);
139
144
  let resolvedAgent = null;
140
145
  const projectDefaultAgent = asNonEmptyString(projectDefaults?.defaultAgent);
141
146
  const settingsDefaultAgent = asNonEmptyString(settings?.defaultAgent);
142
- if (projectDefaultAgent) {
143
- resolvedAgent = agents.find((agent) => agent?.name === projectDefaultAgent) || null;
144
- }
145
- if (!resolvedAgent && settingsDefaultAgent) {
146
- resolvedAgent = agents.find((agent) => agent?.name === settingsDefaultAgent) || null;
147
- }
147
+ // The project's default agent wins over the global one. v1 stored the agent's
148
+ // display name; v2 agents are addressed by id (`build` vs `Build`), so a
149
+ // setting saved before the upgrade still resolves.
150
+ const findAgentBySetting = (wantedName) => {
151
+ const wanted = wantedName.toLowerCase();
152
+ return agents.find((agent) => agent?.id === wantedName)
153
+ || agents.find((agent) => typeof agent?.name === 'string' && agent.name.toLowerCase() === wanted)
154
+ || agents.find((agent) => typeof agent?.id === 'string' && agent.id.toLowerCase() === wanted)
155
+ || null;
156
+ };
157
+ if (projectDefaultAgent) resolvedAgent = findAgentBySetting(projectDefaultAgent);
158
+ if (!resolvedAgent && settingsDefaultAgent) resolvedAgent = findAgentBySetting(settingsDefaultAgent);
148
159
  if (!resolvedAgent && opencodeDefaultAgent) {
149
- const candidate = agents.find((agent) => agent?.name === opencodeDefaultAgent) || null;
160
+ const candidate = agents.find((agent) => agent?.id === opencodeDefaultAgent) || null;
150
161
  if (candidate && isPrimaryAgentMode(candidate.mode) && candidate.hidden !== true) {
151
162
  resolvedAgent = candidate;
152
163
  }
153
164
  }
154
165
  if (!resolvedAgent) {
155
- resolvedAgent = primaryAgents.find((agent) => agent?.name === 'build') || primaryAgents[0] || agents[0] || null;
166
+ resolvedAgent = primaryAgents.find((agent) => agent?.id === 'build') || primaryAgents[0] || agents[0] || null;
156
167
  }
157
168
 
158
169
  let model = null;
159
170
  let variant;
160
171
  const projectDefaultModel = parseConfigModel(projectDefaults?.defaultModel);
161
172
  const settingsDefaultModel = parseConfigModel(settings?.defaultModel);
173
+ // A saved choice is honoured even when the catalog has not listed it yet: a
174
+ // discovery gap must not silently move the user onto another model.
162
175
  if (projectDefaultModel) {
163
176
  model = projectDefaultModel;
164
- variant = resolveVariant(providers, model.providerID, model.modelID, projectDefaults?.defaultVariant);
177
+ variant = resolveVariant(models, model.providerID, model.modelID, projectDefaults?.defaultVariant);
165
178
  }
166
179
  if (!model && settingsDefaultModel) {
167
180
  model = settingsDefaultModel;
168
- variant = resolveVariant(providers, model.providerID, model.modelID, settings?.defaultVariant);
181
+ variant = resolveVariant(models, model.providerID, model.modelID, settings?.defaultVariant);
169
182
  }
170
183
 
171
- if (!model && resolvedAgent?.model?.providerID && resolvedAgent?.model?.modelID) {
172
- model = { providerID: resolvedAgent.model.providerID, modelID: resolvedAgent.model.modelID };
173
- variant = resolveVariant(providers, model.providerID, model.modelID, resolvedAgent.variant);
184
+ // An agent's model is a v2 `ModelRef`: `id` is the model id, not a composite.
185
+ const agentModel = resolvedAgent?.model;
186
+ if (!model && asNonEmptyString(agentModel?.providerID) && asNonEmptyString(agentModel?.id)) {
187
+ model = { providerID: agentModel.providerID, modelID: agentModel.id };
188
+ variant = resolveVariant(models, model.providerID, model.modelID, agentModel.variant);
174
189
  }
175
190
 
176
- const opencodeModel = parseConfigModel(opencodeDefaultModel);
177
- if (!model && opencodeModel) {
178
- model = opencodeModel;
191
+ if (!model && opencodeDefaultModel) {
192
+ model = opencodeDefaultModel;
179
193
  }
180
194
 
181
- if (!model && hasProviderModel(providers, FALLBACK_PROVIDER_ID, FALLBACK_MODEL_ID)) {
195
+ if (!model && hasCatalogModel(models, FALLBACK_PROVIDER_ID, FALLBACK_MODEL_ID)) {
182
196
  model = { providerID: FALLBACK_PROVIDER_ID, modelID: FALLBACK_MODEL_ID };
183
197
  }
184
198
 
185
199
  if (!model) {
186
- const provider = providers[0];
187
- const firstModel = providerModels(provider)[0];
188
- if (provider?.id && firstModel?.id) {
189
- model = { providerID: provider.id, modelID: firstModel.id };
200
+ const first = models[0];
201
+ if (asNonEmptyString(first?.providerID) && asNonEmptyString(first?.modelID)) {
202
+ model = { providerID: first.providerID, modelID: first.modelID };
190
203
  }
191
204
  }
192
205
 
193
206
  return {
194
- agent: resolvedAgent?.name,
207
+ agent: resolvedAgent?.id,
195
208
  model,
196
209
  variant,
197
210
  };
198
211
  };
199
212
 
200
- const runPromptAsync = async ({ baseUrl, authHeaders, sessionID, directory, payload }) => {
201
- const promptUrl = new URL(`${baseUrl}/session/${encodeURIComponent(sessionID)}/prompt_async`);
202
- promptUrl.searchParams.set('directory', directory);
203
- const response = await fetch(promptUrl.toString(), {
204
- method: 'POST',
205
- headers: {
206
- ...authHeaders,
207
- ...buildDirectoryHeaders(directory),
208
- 'content-type': 'application/json',
209
- accept: 'application/json',
210
- },
211
- body: JSON.stringify(payload),
212
- });
213
-
214
- if (!response.ok) {
215
- const body = await response.text().catch(() => '');
216
- throw new Error(`prompt_async failed (${response.status})${body ? `: ${body}` : ''}`);
213
+ /**
214
+ * v2 selects model and agent per session, not per prompt: the choice is
215
+ * switched once and then persists, so every dispatch sets it explicitly rather
216
+ * than passing it alongside the prompt.
217
+ */
218
+ const applySessionSelection = async ({ client, sessionID, model, agent, variant }) => {
219
+ if (model) {
220
+ await client.session.switchModel({
221
+ sessionID,
222
+ model: { id: model.modelID, providerID: model.providerID, ...(variant ? { variant } : {}) },
223
+ });
217
224
  }
225
+ if (agent) await client.session.switchAgent({ sessionID, agent });
218
226
  };
219
227
 
220
- const createSession = async ({ baseUrl, authHeaders, directory, title }) => {
221
- const sessionUrl = new URL(`${baseUrl}/session`);
222
- sessionUrl.searchParams.set('directory', directory);
223
- const response = await fetch(sessionUrl.toString(), {
224
- method: 'POST',
225
- headers: {
226
- ...authHeaders,
227
- ...buildDirectoryHeaders(directory),
228
- 'content-type': 'application/json',
229
- accept: 'application/json',
230
- },
231
- body: JSON.stringify({ directory, ...(title ? { title } : {}) }),
228
+ const createSession = async ({ client, directory, title }) => {
229
+ const session = await client.session.create({
230
+ location: { directory },
231
+ ...(title ? { title } : {}),
232
232
  });
233
-
234
- if (!response.ok) {
235
- const body = await response.text().catch(() => '');
236
- throw new Error(`session create failed (${response.status})${body ? `: ${body}` : ''}`);
237
- }
238
-
239
- const body = await response.json().catch(() => null);
240
- const sessionID = body?.id || body?.data?.id;
241
- if (!sessionID) {
242
- throw new Error('failed to create session');
243
- }
233
+ const sessionID = asNonEmptyString(session?.id);
234
+ if (!sessionID) throw new Error('failed to create session');
244
235
  return sessionID;
245
236
  };
246
237
 
247
- const forkSession = async ({ client, sessionID, directory, messageID }) => {
248
- const response = await client.session.fork({
238
+ const forkSession = async ({ client, sessionID, messageID }) => {
239
+ const session = await client.session.fork({
249
240
  sessionID,
250
- directory,
251
- ...(messageID ? { messageID } : {}),
241
+ // OpenCode 2.0.8 replaced the SessionForkBoundary object with an optional
242
+ // `before` message id. Omitting it carries the whole session over, which is
243
+ // what the old `{ type: 'through' }` boundary meant.
244
+ ...(messageID ? { before: messageID } : {}),
252
245
  });
253
- const session = response?.data;
254
- if (!session?.id) {
255
- throw new Error('failed to fork session');
256
- }
246
+ if (!asNonEmptyString(session?.id)) throw new Error('failed to fork session');
257
247
  return session;
258
248
  };
259
249
 
260
- const latestCompletedAssistantMessageID = async ({ client, sessionID, directory }) => {
261
- let response;
250
+ const listMessages = async ({ client, sessionID, limit }) => {
251
+ const response = await client.message.list({ sessionID, limit, order: 'desc' });
252
+ return asList(response?.data);
253
+ };
254
+
255
+ const latestCompletedAssistantMessageID = async ({ client, sessionID }) => {
256
+ let messages;
262
257
  try {
263
- response = await client.session.messages({ sessionID, directory, limit: 100 });
258
+ messages = await listMessages({ client, sessionID, limit: 100 });
264
259
  } catch {
265
260
  return null;
266
261
  }
267
- const messages = Array.isArray(response?.data) ? response.data : [];
268
262
  let latest = null;
269
263
  for (const message of messages) {
270
- const info = message?.info;
271
- if (info?.role !== 'assistant' || !Number.isFinite(info?.time?.completed)) continue;
272
- if (!latest || (info.time.created || 0) >= (latest.time?.created || 0)) latest = info;
264
+ if (message?.type !== 'assistant' || !Number.isFinite(message?.time?.completed)) continue;
265
+ if (!latest || (message.time.created || 0) >= (latest.time?.created || 0)) latest = message;
273
266
  }
274
267
  return asNonEmptyString(latest?.id);
275
268
  };
@@ -277,14 +270,12 @@ const latestCompletedAssistantMessageID = async ({ client, sessionID, directory
277
270
  /**
278
271
  * Upper bound on one archive batch.
279
272
  *
280
- * The batch is applied one session at a time against OpenCode, so an unbounded
281
- * list would hold a request open for as long as the list is large. Callers with
282
- * more sessions than this send several batches and keep their own partial
283
- * results.
273
+ * The batch is a bounded amount of work on one request, and callers with more
274
+ * sessions than this send several batches and keep their own partial results.
284
275
  */
285
276
  const MAX_ARCHIVE_BATCH = 500;
286
277
 
287
- const parseArchiveRequest = (payload) => {
278
+ const parseIdBatch = (payload) => {
288
279
  const rawIds = payload?.ids;
289
280
  if (!Array.isArray(rawIds) || rawIds.length === 0) {
290
281
  return { ok: false, error: 'ids must be a non-empty array of session ids' };
@@ -299,13 +290,19 @@ const parseArchiveRequest = (payload) => {
299
290
  if (!id) return { ok: false, error: 'ids must contain non-empty session ids' };
300
291
  ids.push(id);
301
292
  }
293
+ return { ok: true, ids };
294
+ };
295
+
296
+ const parseArchiveRequest = (payload) => {
297
+ const parsed = parseIdBatch(payload);
298
+ if (!parsed.ok) return parsed;
302
299
 
303
300
  const archivedAt = payload?.archivedAt;
304
301
  if (archivedAt !== undefined && (!Number.isSafeInteger(archivedAt) || archivedAt <= 0)) {
305
302
  return { ok: false, error: 'archivedAt must be a positive integer timestamp' };
306
303
  }
307
304
 
308
- return { ok: true, ids, archivedAt: archivedAt ?? Date.now() };
305
+ return { ok: true, ids: parsed.ids, archivedAt: archivedAt ?? Date.now() };
309
306
  };
310
307
 
311
308
  const resolveRequestedDirectory = async ({ payload, readSettingsFromDiskMigrated, sanitizeProjects, validateDirectoryPath }) => {
@@ -336,9 +333,6 @@ const resolveRequestedDirectory = async ({ payload, readSettingsFromDiskMigrated
336
333
  return { ok: true, directory: validated.directory, ...(project ? { projectId: project.id } : {}) };
337
334
  };
338
335
 
339
- const PROMPT_LANDED_TIMEOUT_MS = 5_000;
340
- const PROMPT_LANDED_POLL_MS = 150;
341
-
342
336
  // createWorktree returns while the worktree is still being populated in the
343
337
  // background (git reset --hard after a --no-checkout add). Dispatching a
344
338
  // prompt into a half-populated directory makes opencode's run die with
@@ -348,54 +342,6 @@ const PROMPT_LANDED_POLL_MS = 150;
348
342
  const WORKTREE_BOOTSTRAP_TIMEOUT_MS = 60_000;
349
343
  const WORKTREE_BOOTSTRAP_POLL_MS = 150;
350
344
 
351
- const waitForWorktreeBootstrapReady = async ({ directory }) => {
352
- const deadline = Date.now() + WORKTREE_BOOTSTRAP_TIMEOUT_MS;
353
- for (;;) {
354
- const status = await getWorktreeBootstrapStatus(directory);
355
- if (status?.status === 'failed') {
356
- throw new OpenChamberControlError(`Worktree bootstrap failed: ${status.error || 'unknown error'}`, 500);
357
- }
358
- const phase = status?.phase;
359
- if (status?.status === 'ready' || phase === 'git-ready' || phase === 'setup-ready') return;
360
- if (Date.now() >= deadline) {
361
- throw new OpenChamberControlError('Timed out waiting for the worktree bootstrap', 500);
362
- }
363
- await new Promise((resolve) => setTimeout(resolve, WORKTREE_BOOTSTRAP_POLL_MS));
364
- }
365
- };
366
-
367
- const latestUserMessageID = async ({ client, sessionID, directory }) => {
368
- let response;
369
- try {
370
- response = await client.session.messages({ sessionID, directory, limit: 100 });
371
- } catch {
372
- return { ok: false, messageID: null };
373
- }
374
- const messages = Array.isArray(response?.data) ? response.data : [];
375
- let latest = null;
376
- for (const message of messages) {
377
- const info = message?.info;
378
- if (info?.role !== 'user') continue;
379
- if (!latest || (info.time?.created || 0) >= (latest.time?.created || 0)) latest = info;
380
- }
381
- return { ok: true, messageID: asNonEmptyString(latest?.id) };
382
- };
383
-
384
- // `prompt_async` answers 204 as soon as OpenCode forks the run, and every later
385
- // failure is reported only on the session event stream. Confirm the prompt was
386
- // actually recorded so `promptDispatched` never claims a dispatch that vanished.
387
- const waitForPromptLanded = async ({ client, sessionID, directory, baselineUserMessageID }) => {
388
- const deadline = Date.now() + PROMPT_LANDED_TIMEOUT_MS;
389
- for (;;) {
390
- const latest = await latestUserMessageID({ client, sessionID, directory });
391
- // A failed lookup is not authoritative evidence that the prompt was lost.
392
- if (!latest.ok) return true;
393
- if (latest.messageID && latest.messageID !== baselineUserMessageID) return true;
394
- if (Date.now() >= deadline) return false;
395
- await new Promise((resolve) => setTimeout(resolve, PROMPT_LANDED_POLL_MS));
396
- }
397
- };
398
-
399
345
  const resolveWorktreeInput = (payload) => {
400
346
  if (!payload?.worktree || typeof payload.worktree !== 'object') return null;
401
347
  const name = asNonEmptyString(payload.worktree.name);
@@ -420,54 +366,95 @@ export const createOpenChamberSessionService = (dependencies) => {
420
366
  getOpenCodeAuthHeaders,
421
367
  waitForOpenCodeReady,
422
368
  emitSessionCreatedEvent,
369
+ broadcastGlobalUiEvent,
423
370
  createSessionGoal: createSessionGoalOverride,
424
371
  sessionKnowledgeRuntime = null,
425
- // Auto routing. Prompts dispatched here go straight to OpenCode, not
426
- // through the proxy that rewrites the Auto sentinel, so the same hook runs
427
- // on the body before it is sent. Null when routing is not wired in.
428
- resolvePromptBody = null,
372
+ dataDir = null,
373
+ archiveStore: injectedArchiveStore = null,
374
+ sessionMetadataStore: injectedSessionMetadataStore = null,
375
+ // Injected by the server so every metadata write takes the same path:
376
+ // store, broadcast, and tell the goal loop. Falls back to store+broadcast
377
+ // when it is absent, which is what module tests use.
378
+ persistSessionMetadata = null,
379
+ createOpenCodeClient = defaultCreateOpenCodeClient,
380
+ createWorktree = createWorktreeDefault,
381
+ getWorktreeBootstrapStatus = getWorktreeBootstrapStatusDefault,
382
+ // Auto routing. Sessions dispatched here talk to OpenCode through the SDK,
383
+ // not through the proxy that intercepts the Auto sentinel, so a default of
384
+ // `openchamber/auto` (Session Defaults) is resolved here before the
385
+ // session is switched onto it. Null when routing is not wired in.
386
+ resolveAutoSelection = null,
429
387
  } = dependencies;
430
388
 
431
- // Last user message of an existing session, as a selection to reuse. Returns
432
- // null when the session has no user message carrying a model.
433
- const fetchLastUserSelection = async ({ client, sessionID, directory }) => {
434
- try {
435
- const response = await client.session.messages({ sessionID, directory, limit: 20 });
436
- const records = Array.isArray(response?.data) ? response.data : [];
437
- for (let index = records.length - 1; index >= 0; index -= 1) {
438
- const info = records[index]?.info;
439
- if (info?.role !== 'user') continue;
440
- const providerID = asNonEmptyString(info.model?.providerID);
441
- const modelID = asNonEmptyString(info.model?.modelID);
442
- if (!providerID || !modelID) continue;
443
- return {
444
- model: { providerID, modelID },
445
- agent: asNonEmptyString(info.agent),
446
- variant: asNonEmptyString(info.model?.variant),
447
- };
389
+ if ((!injectedArchiveStore || !injectedSessionMetadataStore) && !dataDir) {
390
+ throw new Error('openchamber session routes need either both stores or a dataDir');
391
+ }
392
+ const archiveStore = injectedArchiveStore || createArchiveStore({ dataDir });
393
+ const sessionMetadataStore = injectedSessionMetadataStore || createSessionMetadataStore({
394
+ dataDir,
395
+ openCode: createOpenCodeSessionMetadata({
396
+ buildOpenCodeUrl,
397
+ getOpenCodeAuthHeaders,
398
+ createOpenCodeClient,
399
+ }),
400
+ });
401
+
402
+ const openCodeBaseUrl = () => buildOpenCodeUrl('/', '').replace(/\/$/, '');
403
+ const clientFor = (directory) => createOpenCodeClient({
404
+ baseUrl: openCodeBaseUrl(),
405
+ headers: getOpenCodeAuthHeaders(),
406
+ directory,
407
+ });
408
+
409
+ const waitForWorktreeBootstrapReady = async ({ directory }) => {
410
+ const deadline = Date.now() + WORKTREE_BOOTSTRAP_TIMEOUT_MS;
411
+ for (;;) {
412
+ const status = await getWorktreeBootstrapStatus(directory);
413
+ if (status?.status === 'failed') {
414
+ throw new OpenChamberControlError(`Worktree bootstrap failed: ${status.error || 'unknown error'}`, 500);
448
415
  }
416
+ const phase = status?.phase;
417
+ if (status?.status === 'ready' || phase === 'git-ready' || phase === 'setup-ready') return;
418
+ if (Date.now() >= deadline) {
419
+ throw new OpenChamberControlError('Timed out waiting for the worktree bootstrap', 500);
420
+ }
421
+ await new Promise((resolve) => setTimeout(resolve, WORKTREE_BOOTSTRAP_POLL_MS));
422
+ }
423
+ };
424
+
425
+ /**
426
+ * The selection an existing session already runs on. v2 keeps it on the
427
+ * session record, so there is no need to walk the message history for it.
428
+ */
429
+ const fetchSessionSelection = async ({ client, sessionID }) => {
430
+ try {
431
+ const session = await client.session.get({ sessionID });
432
+ const providerID = asNonEmptyString(session?.model?.providerID);
433
+ const modelID = asNonEmptyString(session?.model?.id);
434
+ return {
435
+ model: providerID && modelID ? { providerID, modelID } : null,
436
+ agent: asNonEmptyString(session?.agent),
437
+ variant: asNonEmptyString(session?.model?.variant),
438
+ };
449
439
  } catch {
440
+ return null;
450
441
  }
451
- return null;
452
442
  };
453
443
 
454
- // Explicit model/agent/variant are never checked by `prompt_async`: an unknown
455
- // agent makes the forked run fail silently, leaving a session with no message.
456
- // Reject them before any session, worktree, or goal side effect happens.
444
+ // An unknown agent or model makes the run fail after the prompt is accepted,
445
+ // leaving a session with no answer. Reject them before any session, worktree,
446
+ // or goal side effect happens.
457
447
  const validateRequestedSelection = async ({ directory, requestedModel, requestedAgent, requestedVariant }) => {
458
448
  if (!requestedModel && !requestedAgent && !requestedVariant) return;
459
- const authHeaders = getOpenCodeAuthHeaders();
460
- const { providers, agents } = await fetchSelectionInputs({
461
- buildOpenCodeUrl,
462
- authHeaders,
463
- directory,
449
+ const { models, agents } = await fetchSelectionInputs({
450
+ client: clientFor(directory),
464
451
  readSettingsFromDiskMigrated,
465
452
  });
466
453
 
467
454
  // An empty list means the lookup failed or returned nothing authoritative;
468
455
  // it must not turn a valid selection into a rejection.
469
456
  if (requestedAgent && agents.length > 0) {
470
- const agent = agents.find((entry) => entry?.name === requestedAgent) || null;
457
+ const agent = agents.find((entry) => entry?.id === requestedAgent) || null;
471
458
  if (!agent) {
472
459
  throw new OpenChamberControlError(`Unknown agent '${requestedAgent}' for ${directory}`, 400);
473
460
  }
@@ -476,15 +463,15 @@ export const createOpenChamberSessionService = (dependencies) => {
476
463
  }
477
464
  }
478
465
 
479
- if (requestedModel && providers.length > 0) {
480
- if (!hasProviderModel(providers, requestedModel.providerID, requestedModel.modelID)) {
466
+ if (requestedModel && models.length > 0) {
467
+ if (!hasCatalogModel(models, requestedModel.providerID, requestedModel.modelID)) {
481
468
  throw new OpenChamberControlError(
482
469
  `Unknown model '${requestedModel.providerID}/${requestedModel.modelID}' for ${directory}`,
483
470
  400,
484
471
  );
485
472
  }
486
473
  if (requestedVariant
487
- && !resolveVariant(providers, requestedModel.providerID, requestedModel.modelID, requestedVariant)) {
474
+ && !resolveVariant(models, requestedModel.providerID, requestedModel.modelID, requestedVariant)) {
488
475
  throw new OpenChamberControlError(
489
476
  `Unknown variant '${requestedVariant}' for model '${requestedModel.providerID}/${requestedModel.modelID}'`,
490
477
  400,
@@ -511,7 +498,7 @@ export const createOpenChamberSessionService = (dependencies) => {
511
498
  let agent = requestedAgent;
512
499
  let variant = requestedVariant;
513
500
  if (reuseSessionSelection && (!model || !agent)) {
514
- const previous = await fetchLastUserSelection({ client, sessionID, directory });
501
+ const previous = await fetchSessionSelection({ client, sessionID });
515
502
  if (previous) {
516
503
  if (!model && previous.model) {
517
504
  model = previous.model;
@@ -521,12 +508,7 @@ export const createOpenChamberSessionService = (dependencies) => {
521
508
  }
522
509
  }
523
510
  if (!model || !agent) {
524
- const inputs = await fetchSelectionInputs({
525
- buildOpenCodeUrl,
526
- authHeaders,
527
- directory,
528
- readSettingsFromDiskMigrated,
529
- });
511
+ const inputs = await fetchSelectionInputs({ client, readSettingsFromDiskMigrated });
530
512
  const defaults = resolveDefaultSelection({
531
513
  ...inputs,
532
514
  projectDefaults: resolveProjectDefaults(inputs.settings, directory, projectId),
@@ -544,31 +526,53 @@ export const createOpenChamberSessionService = (dependencies) => {
544
526
  }
545
527
 
546
528
  const expandedPrompt = expandSnippets(prompt, directory);
529
+ if (isAutoModel(model)) {
530
+ // The sentinel must never reach OpenCode: neither the goal record nor the
531
+ // session switch below may carry it.
532
+ if (!resolveAutoSelection) {
533
+ throw new OpenChamberControlError('Auto routing is not available on this server. Choose a model.', 400);
534
+ }
535
+ const routed = await resolveAutoSelection({
536
+ sessionId: sessionID,
537
+ directory,
538
+ model: AUTO_MODEL_REF,
539
+ agent: agent ?? null,
540
+ requestText: expandedPrompt,
541
+ });
542
+ model = { providerID: routed.model.providerID, modelID: routed.model.id };
543
+ variant = routed.model.variant ?? undefined;
544
+ agent = routed.agent || agent;
545
+ }
547
546
  const parsedCommand = parseScheduledCommandPrompt(prompt);
548
547
  let resolvedCommand = null;
549
548
  if (parsedCommand) {
550
549
  try {
551
- const response = await client.command.list({ directory });
552
- const commands = Array.isArray(response?.data) ? response.data : [];
553
- const command = commands.find((candidate) => candidate?.name === parsedCommand.command);
554
- if (command) resolvedCommand = { ...parsedCommand, template: command.template };
550
+ const response = await client.command.list();
551
+ const commands = asList(response?.data);
552
+ if (commands.some((candidate) => candidate?.name === parsedCommand.command)) {
553
+ resolvedCommand = parsedCommand;
554
+ }
555
555
  } catch {
556
556
  }
557
557
  }
558
558
  if (goalInput.enabled) {
559
- const commandObjective = resolvedCommand
560
- ? expandCommandGoalObjective(resolvedCommand.template, resolvedCommand.arguments)
561
- : null;
559
+ // v2 no longer publishes a command's template, so a slash command's goal
560
+ // objective is the prompt the user typed rather than the expanded body.
562
561
  await (createSessionGoalOverride || createSessionGoal)({
563
562
  baseUrl,
564
563
  authHeaders,
565
564
  sessionID,
566
565
  directory,
567
- objective: commandObjective ?? expandedPrompt,
566
+ objective: expandedPrompt,
568
567
  tokenBudget: goalInput.tokenBudget,
569
568
  providerID: model.providerID,
570
569
  modelID: model.modelID,
571
570
  onWarning: (message, error) => console.warn(`[OpenChamberSessions] ${message}:`, error?.message || error),
571
+ // v2 has no session-metadata route, so the goal record goes to
572
+ // OpenChamber's own store — the same one the proxy overlays back.
573
+ persistSessionGoal: async (goalSessionID, goalDirectory, goal) => {
574
+ await writeMetadata(goalSessionID, { openchamber: { goal } }, goalDirectory);
575
+ },
572
576
  });
573
577
  }
574
578
 
@@ -577,66 +581,75 @@ export const createOpenChamberSessionService = (dependencies) => {
577
581
  return error;
578
582
  };
579
583
 
584
+ try {
585
+ await applySessionSelection({ client, sessionID, model, agent, variant });
586
+ } catch (error) {
587
+ throw markGoalPartial(error);
588
+ }
589
+
590
+ // A session the agent dispatched has no UI to attach the project's
591
+ // standing context, so it is asked for here. Never fails the dispatch:
592
+ // a session that runs without its background beats one that never runs.
593
+ const knowledge = sessionKnowledgeRuntime
594
+ ? await sessionKnowledgeRuntime.resolvePendingForSession(sessionID, directory)
595
+ .catch(() => ({ text: '', signature: '' }))
596
+ : { text: '', signature: '' };
597
+ // After the send is accepted, so a rejected dispatch carries it again.
598
+ const recordKnowledge = async () => {
599
+ if (knowledge.text && sessionKnowledgeRuntime) {
600
+ await sessionKnowledgeRuntime.recordDelivered(sessionID, directory, knowledge.signature)
601
+ .catch(() => undefined);
602
+ }
603
+ };
604
+
580
605
  if (resolvedCommand) {
581
606
  try {
607
+ // The command route takes no extra parts, so the context goes in
608
+ // first as a synthetic message that does not start execution.
609
+ if (knowledge.text) {
610
+ await client.session.synthetic({ sessionID, text: knowledge.text, resume: false });
611
+ }
582
612
  await client.session.command({
583
613
  sessionID,
584
- directory,
585
- command: resolvedCommand.command,
586
- arguments: resolvedCommand.arguments,
587
- ...(agent ? { agent } : {}),
588
- model: `${model.providerID}/${model.modelID}`,
589
- ...(variant ? { variant } : {}),
614
+ // OpenCode 2.0.8 renamed the command body field `command` to `name`.
615
+ name: resolvedCommand.command,
616
+ text: resolvedCommand.arguments || '',
590
617
  });
591
618
  } catch (error) {
592
619
  throw markGoalPartial(error);
593
620
  }
621
+ await recordKnowledge();
594
622
  } else {
595
- const baseline = await latestUserMessageID({ client, sessionID, directory });
596
- // A session the agent dispatched has no UI to attach the project's
597
- // standing context, so it is asked for here. Never fails the dispatch:
598
- // a session that runs without its background beats one that never runs.
599
- const knowledge = sessionKnowledgeRuntime
600
- ? await sessionKnowledgeRuntime.resolvePendingForSession(sessionID, directory)
601
- .catch(() => ({ text: '', signature: '' }))
602
- : { text: '', signature: '' };
603
- const payload = {
604
- model,
605
- ...(agent ? { agent } : {}),
606
- ...(variant ? { variant } : {}),
607
- parts: [
608
- ...(knowledge.text ? [{ type: 'text', text: knowledge.text, synthetic: true }] : []),
609
- { type: 'text', text: expandedPrompt },
610
- ...(goalInput.enabled
611
- ? [{ type: 'text', text: buildGoalIntroText(goalInput.tokenBudget), synthetic: true }]
612
- : []),
613
- ],
614
- };
615
- await resolvePromptBody?.(payload, { sessionId: sessionID, directory });
623
+ let landedMessageID = null;
616
624
  try {
617
- await runPromptAsync({ baseUrl, authHeaders, sessionID, directory, payload });
625
+ if (knowledge.text) {
626
+ await client.session.synthetic({ sessionID, text: knowledge.text, resume: false });
627
+ }
628
+ const sent = await client.session.prompt({ sessionID, text: expandedPrompt });
629
+ landedMessageID = asNonEmptyString(sent?.id);
630
+ if (goalInput.enabled) {
631
+ // Goal mode's reminder refers to "the user message above", so it is
632
+ // admitted after the prompt; v2 has no way to append to one message.
633
+ await client.session.synthetic({
634
+ sessionID,
635
+ text: buildGoalIntroText(goalInput.tokenBudget),
636
+ resume: false,
637
+ });
638
+ }
618
639
  } catch (error) {
619
640
  throw markGoalPartial(error);
620
641
  }
621
- if (knowledge.text && sessionKnowledgeRuntime) {
622
- // After the prompt is accepted, so a rejected dispatch carries it again.
623
- await sessionKnowledgeRuntime.recordDelivered(sessionID, directory, knowledge.signature)
624
- .catch(() => undefined);
625
- }
626
- const landed = await waitForPromptLanded({
627
- client,
628
- sessionID,
629
- directory,
630
- baselineUserMessageID: baseline.messageID,
631
- });
632
- if (!landed) {
642
+ await recordKnowledge();
643
+ if (!landedMessageID) {
644
+ // v2 answers a prompt with the inbox item it recorded. No item id means
645
+ // nothing is queued, so the dispatch must not be claimed as done.
633
646
  return {
634
647
  model,
635
648
  agent,
636
649
  variant,
637
650
  promptDispatched: false,
638
651
  dispatchedAsCommand: false,
639
- promptError: 'OpenCode accepted the prompt but it never appeared in the session',
652
+ promptError: 'OpenCode accepted the prompt but returned no queued message',
640
653
  };
641
654
  }
642
655
  }
@@ -644,20 +657,63 @@ export const createOpenChamberSessionService = (dependencies) => {
644
657
  return { model, agent, variant, promptDispatched: true, dispatchedAsCommand: Boolean(resolvedCommand) };
645
658
  };
646
659
 
660
+ const broadcastMetadata = (sessionID, metadata) => {
661
+ broadcastGlobalUiEvent?.({
662
+ type: 'openchamber:session-metadata',
663
+ properties: { sessionID, metadata },
664
+ });
665
+ };
666
+
667
+ /**
668
+ * Merge-patch a session's OpenChamber metadata on its OpenCode record: the
669
+ * per-session state of goal mode, session assist, obligatory context and
670
+ * pinned notes. The broadcast carries the full merged object, because a
671
+ * client that missed an earlier patch must not have to reconstruct it.
672
+ */
673
+ const writeMetadata = async (sessionID, patch, directory = '') => {
674
+ if (typeof persistSessionMetadata === 'function') {
675
+ return persistSessionMetadata(sessionID, patch, { directory });
676
+ }
677
+ const metadata = await sessionMetadataStore.setSessionMetadata(sessionID, patch, { directory });
678
+ broadcastMetadata(sessionID, metadata);
679
+ return metadata;
680
+ };
681
+
682
+ const setMetadata = async (sessionID, payload = {}) => {
683
+ const id = asNonEmptyString(sessionID);
684
+ if (!id) throw new OpenChamberControlError('a session id is required', 400);
685
+ const patch = payload?.patch;
686
+ if (!patch || typeof patch !== 'object' || Array.isArray(patch)) {
687
+ throw new OpenChamberControlError('patch must be an object', 400);
688
+ }
689
+
690
+ return { metadata: await writeMetadata(id, patch, asNonEmptyString(payload?.directory) || '') };
691
+ };
692
+
693
+ const getMetadata = async (sessionID, directory = '') => {
694
+ const id = asNonEmptyString(sessionID);
695
+ if (!id) throw new OpenChamberControlError('a session id is required', 400);
696
+ return { metadata: await sessionMetadataStore.get(id, { directory }) };
697
+ };
698
+
699
+ const broadcastArchived = (sessionID, archivedAt) => {
700
+ broadcastGlobalUiEvent?.({
701
+ type: 'openchamber:session-archived',
702
+ properties: { sessionID, archivedAt },
703
+ });
704
+ };
705
+
647
706
  /**
648
707
  * Archive a batch of sessions in one request.
649
708
  *
650
- * The UI archives every session linked to a worktree before removing it.
651
- * Doing that from the browser costs one request per session plus a store
652
- * reconciliation between each of them, which is what made deleting a
653
- * worktree with many sessions take tens of seconds. Here the batch stays on
654
- * the server, next to OpenCode, and the client reconciles once.
709
+ * OpenCode 2.x has no route that sets `time.archived`, so the state is
710
+ * OpenChamber's own: a JSON file beside the OpenCode instance, folded back
711
+ * onto the session records the proxy serves. Batching matters for the same
712
+ * reason it did before — the UI archives every session linked to a worktree
713
+ * before removing it, and doing that one request at a time is what made
714
+ * removing a busy worktree take tens of seconds.
655
715
  *
656
- * Sessions are updated one at a time on purpose: they are archived against a
657
- * single OpenCode instance, and a fan-out of concurrent writes would trade a
658
- * UI stall for server event-loop starvation. One failed session never stops
659
- * the batch — it is reported in `failedIds` while the rest still archive, so
660
- * callers keep the partial-failure behaviour they already show.
716
+ * No directory is needed: archive state is keyed by session id per data dir.
661
717
  */
662
718
  const archive = async (payload = {}) => {
663
719
  const parsed = parseArchiveRequest(payload);
@@ -665,41 +721,21 @@ export const createOpenChamberSessionService = (dependencies) => {
665
721
  throw new OpenChamberControlError(parsed.error, 400);
666
722
  }
667
723
 
668
- const resolvedDirectory = await resolveRequestedDirectory({
669
- payload,
670
- readSettingsFromDiskMigrated,
671
- sanitizeProjects,
672
- validateDirectoryPath,
673
- });
674
- if (!resolvedDirectory.ok) {
675
- throw new OpenChamberControlError(resolvedDirectory.error, resolvedDirectory.status || 400);
676
- }
677
-
678
- if (typeof waitForOpenCodeReady === 'function') await waitForOpenCodeReady(10_000, 250);
679
-
680
- const directory = resolvedDirectory.directory;
681
- const baseUrl = buildOpenCodeUrl('/', '').replace(/\/$/, '');
682
- const client = createOpencodeClient({ baseUrl, headers: getOpenCodeAuthHeaders() });
724
+ const { archived, failedIds } = await archiveStore.archive(parsed.ids, parsed.archivedAt);
725
+ for (const entry of archived) broadcastArchived(entry.id, entry.archivedAt);
726
+ return { archived, failedIds };
727
+ };
683
728
 
684
- const archived = [];
685
- const failedIds = [];
686
- for (const sessionID of parsed.ids) {
687
- try {
688
- const response = await client.session.update({
689
- sessionID,
690
- directory,
691
- time: { archived: parsed.archivedAt },
692
- });
693
- const session = response?.data;
694
- if (session?.id) archived.push(session);
695
- else failedIds.push(sessionID);
696
- } catch (error) {
697
- console.warn('[OpenChamberSessions] failed to archive session', sessionID, error);
698
- failedIds.push(sessionID);
699
- }
729
+ /** Clears the archive flag for a batch. Mirrors `archive`. */
730
+ const unarchive = async (payload = {}) => {
731
+ const parsed = parseIdBatch(payload);
732
+ if (!parsed.ok) {
733
+ throw new OpenChamberControlError(parsed.error, 400);
700
734
  }
701
735
 
702
- return { directory, archived, failedIds };
736
+ const { restored, failedIds } = await archiveStore.unarchive(parsed.ids);
737
+ for (const entry of restored) broadcastArchived(entry.id, null);
738
+ return { restored, failedIds };
703
739
  };
704
740
 
705
741
  const create = async (payload = {}) => {
@@ -747,13 +783,11 @@ export const createOpenChamberSessionService = (dependencies) => {
747
783
  await waitForWorktreeBootstrapReady({ directory: sessionDirectory });
748
784
  }
749
785
 
750
- const baseUrl = buildOpenCodeUrl('/', '').replace(/\/$/, '');
786
+ const baseUrl = openCodeBaseUrl();
751
787
  const authHeaders = getOpenCodeAuthHeaders();
752
- const client = createOpencodeClient({ baseUrl, headers: authHeaders });
788
+ const client = clientFor(sessionDirectory);
753
789
  const sessionID = await createSession({
754
790
  client,
755
- baseUrl,
756
- authHeaders,
757
791
  directory: sessionDirectory,
758
792
  ...(title ? { title } : {}),
759
793
  });
@@ -845,14 +879,13 @@ export const createOpenChamberSessionService = (dependencies) => {
845
879
  requestedVariant: asNonEmptyString(payload.variant),
846
880
  });
847
881
 
848
- const baseUrl = buildOpenCodeUrl('/', '').replace(/\/$/, '');
882
+ const baseUrl = openCodeBaseUrl();
849
883
  const authHeaders = getOpenCodeAuthHeaders();
850
- const client = createOpencodeClient({ baseUrl, headers: authHeaders });
884
+ const client = clientFor(directory);
851
885
  if (action === 'fork') {
852
886
  targetSession = await forkSession({
853
887
  client,
854
888
  sessionID: sourceSessionID,
855
- directory,
856
889
  messageID: asNonEmptyString(payload.messageId) || undefined,
857
890
  });
858
891
  targetSessionID = targetSession.id;
@@ -861,7 +894,6 @@ export const createOpenChamberSessionService = (dependencies) => {
861
894
  const baselineAssistantMessageId = await latestCompletedAssistantMessageID({
862
895
  client,
863
896
  sessionID: targetSessionID,
864
- directory,
865
897
  });
866
898
 
867
899
  const dispatch = await dispatchPrompt({
@@ -939,6 +971,11 @@ export const createOpenChamberSessionService = (dependencies) => {
939
971
  return {
940
972
  create,
941
973
  archive,
974
+ unarchive,
975
+ archiveStore,
976
+ sessionMetadataStore,
977
+ setMetadata,
978
+ getMetadata,
942
979
  send: (sessionID, payload) => runExisting('send', sessionID, payload),
943
980
  fork: (sessionID, payload) => runExisting('fork', sessionID, payload),
944
981
  };
@@ -978,6 +1015,39 @@ export const registerOpenChamberSessionRoutes = (app, dependencies) => {
978
1015
  }
979
1016
  });
980
1017
 
1018
+ app.get('/api/openchamber/sessions/:sessionId/metadata', async (req, res) => {
1019
+ try {
1020
+ return res.json(await service.getMetadata(
1021
+ req.params.sessionId,
1022
+ asNonEmptyString(req.query?.directory) || '',
1023
+ ));
1024
+ } catch (error) {
1025
+ console.error('[OpenChamberSessions] failed to read session metadata:', error);
1026
+ return sendServiceError(res, error, 'Failed to read session metadata');
1027
+ }
1028
+ });
1029
+
1030
+ app.post('/api/openchamber/sessions/:sessionId/metadata', express.json({ limit: '1mb' }), async (req, res) => {
1031
+ try {
1032
+ return res.json(await service.setMetadata(
1033
+ req.params.sessionId,
1034
+ req.body && typeof req.body === 'object' ? req.body : {},
1035
+ ));
1036
+ } catch (error) {
1037
+ console.error('[OpenChamberSessions] failed to store session metadata:', error);
1038
+ return sendServiceError(res, error, 'Failed to store session metadata');
1039
+ }
1040
+ });
1041
+
1042
+ app.post('/api/openchamber/sessions/unarchive', express.json({ limit: '1mb' }), async (req, res) => {
1043
+ try {
1044
+ return res.json(await service.unarchive(req.body && typeof req.body === 'object' ? req.body : {}));
1045
+ } catch (error) {
1046
+ console.error('[OpenChamberSessions] failed to unarchive sessions:', error);
1047
+ return sendServiceError(res, error, 'Failed to unarchive sessions');
1048
+ }
1049
+ });
1050
+
981
1051
  app.post(
982
1052
  '/api/openchamber/sessions/:sessionId/send',
983
1053
  express.json({ limit: '1mb' }),