@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
@@ -33,12 +33,19 @@ afterEach(() => {
33
33
  */
34
34
  const createOpenCode = () => {
35
35
  const state = {
36
- statuses: {},
36
+ // `/api/session/active` lists only running sessions; an absent id is idle.
37
+ active: {},
38
+ // Subagent sessions per parent, as `GET /api/session?parentID=` lists them.
39
+ children: {},
37
40
  tail: [],
38
41
  commands: [],
39
42
  sent: [],
43
+ switched: [],
40
44
  failNext: null,
41
45
  };
46
+ // v2 wraps `/api/*` payloads in `{ location, data }`; a message page is
47
+ // `{ data, cursor }` and lists newest first.
48
+ const wrapped = (data) => Response.json({ location: { directory: DIRECTORY }, data });
42
49
  const fetchImpl = vi.fn(async (url, init = {}) => {
43
50
  const { pathname } = new URL(url);
44
51
  const method = init.method ?? 'GET';
@@ -46,19 +53,29 @@ const createOpenCode = () => {
46
53
  state.failNext = null;
47
54
  return new Response('boom', { status: 500 });
48
55
  }
49
- if (pathname === '/session/status') return Response.json(state.statuses);
50
- if (pathname.endsWith('/message')) return Response.json(state.tail);
51
- if (pathname === '/command') return Response.json(state.commands);
52
- if (method === 'POST' && (pathname.endsWith('/prompt_async') || pathname.endsWith('/command'))) {
53
- state.sent.push({ path: pathname, body: JSON.parse(init.body) });
56
+ // Real shape: `{ data }` without a `location`, unlike directory-scoped routes.
57
+ if (pathname === '/api/session/active') return Response.json({ data: state.active });
58
+ if (pathname === '/api/session' && method === 'GET') {
59
+ const parentID = new URL(url).searchParams.get('parentID');
60
+ return Response.json({ data: (state.children[parentID] ?? []).map((id) => ({ id, parentID })), cursor: {} });
61
+ }
62
+ if (pathname.endsWith('/message')) return Response.json({ data: state.tail, cursor: {} });
63
+ if (pathname === '/api/command') return wrapped(state.commands);
64
+ if (method === 'POST' && (pathname.endsWith('/model') || pathname.endsWith('/agent'))) {
65
+ // Kept apart from `sent`: switching the session is not a message.
66
+ state.switched.push({ path: pathname, body: JSON.parse(init.body) });
54
67
  return new Response(null, { status: 204 });
55
68
  }
69
+ if (method === 'POST' && (pathname.endsWith('/prompt') || pathname.endsWith('/command') || pathname.endsWith('/synthetic'))) {
70
+ state.sent.push({ path: pathname, body: JSON.parse(init.body) });
71
+ return wrapped({ id: `msg_${state.sent.length}` });
72
+ }
56
73
  return new Response('not found', { status: 404 });
57
74
  });
58
75
  return { state, fetchImpl };
59
76
  };
60
77
 
61
- const createRuntime = ({ dataDir = makeDataDir(), openCode = createOpenCode(), knowledge = null, retryDelayMs, resolvePromptBody, now } = {}) => {
78
+ const createRuntime = ({ dataDir = makeDataDir(), openCode = createOpenCode(), knowledge = null, retryDelayMs, resolveAutoSelection, now } = {}) => {
62
79
  let eventHandler = () => {};
63
80
  let statusHandler = () => {};
64
81
  const broadcasts = [];
@@ -79,7 +96,7 @@ const createRuntime = ({ dataDir = makeDataDir(), openCode = createOpenCode(), k
79
96
  abortHoldMs: 50,
80
97
  };
81
98
  if (retryDelayMs) options.retryDelayMs = retryDelayMs;
82
- if (resolvePromptBody) options.resolvePromptBody = resolvePromptBody;
99
+ if (resolveAutoSelection) options.resolveAutoSelection = resolveAutoSelection;
83
100
  if (now) options.now = now;
84
101
  const runtime = createMessageQueueRuntime(options);
85
102
  return {
@@ -88,7 +105,8 @@ const createRuntime = ({ dataDir = makeDataDir(), openCode = createOpenCode(), k
88
105
  dataDir,
89
106
  broadcasts,
90
107
  promptSent,
91
- emit: (payload, directory = DIRECTORY) => eventHandler({ payload, directory }),
108
+ // The hub hands server-side subscribers already-translated events.
109
+ emit: (payload, directory = DIRECTORY) => eventHandler({ payload, directory, translated: () => [payload] }),
92
110
  connect: () => statusHandler({ type: 'connect' }),
93
111
  };
94
112
  };
@@ -98,13 +116,11 @@ const settle = async (ms = 30) => {
98
116
  };
99
117
 
100
118
  describe('auto routing', () => {
101
- it('lets the routing hook rewrite the model of a queued prompt and a queued command', async () => {
102
- const resolvePromptBody = vi.fn(async (body) => {
103
- if (body.model?.modelID === 'auto') body.model = { providerID: 'openai', modelID: 'gpt-6-astra' };
104
- if (body.model === 'openchamber/auto') body.model = 'openai/gpt-6-astra';
105
- return null;
106
- });
107
- const { runtime, openCode, emit } = createRuntime({ resolvePromptBody });
119
+ it('switches a queued prompt and a queued command onto the routed model and agent', async () => {
120
+ const resolveAutoSelection = vi.fn(async ({ model }) => (model?.id === 'auto'
121
+ ? { model: { providerID: 'openai', id: 'gpt-6-astra' }, agent: 'plan', decision: {} }
122
+ : null));
123
+ const { runtime, openCode, emit } = createRuntime({ resolveAutoSelection });
108
124
  runtime.start();
109
125
  openCode.state.statuses = { [SESSION]: { type: 'busy' } };
110
126
  openCode.state.commands = [{ name: 'review', template: 'Review $ARGUMENTS' }];
@@ -118,12 +134,16 @@ describe('auto routing', () => {
118
134
  emit({ type: 'session.status', properties: { sessionID: SESSION, status: { type: 'idle' } } });
119
135
  await settle();
120
136
 
121
- expect(openCode.state.sent.map((entry) => entry.body.model)).toEqual([
122
- { providerID: 'openai', modelID: 'gpt-6-astra' },
123
- 'openai/gpt-6-astra',
137
+ // v2 carries no model in a prompt body: the session is switched first.
138
+ const switched = openCode.state.switched.filter((entry) => entry.path.endsWith('/model'));
139
+ expect(switched.map((entry) => entry.body.model)).toEqual([
140
+ { providerID: 'openai', id: 'gpt-6-astra' },
141
+ { providerID: 'openai', id: 'gpt-6-astra' },
124
142
  ]);
125
- expect(resolvePromptBody).toHaveBeenCalledTimes(2);
126
- expect(resolvePromptBody.mock.calls[0][1]).toEqual({ sessionId: SESSION, directory: DIRECTORY });
143
+ expect(openCode.state.switched.filter((entry) => entry.path.endsWith('/agent')).map((entry) => entry.body.agent))
144
+ .toEqual(['plan', 'plan']);
145
+ expect(resolveAutoSelection).toHaveBeenCalledTimes(2);
146
+ expect(resolveAutoSelection.mock.calls[0][0]).toMatchObject({ sessionId: SESSION, directory: DIRECTORY, requestText: 'plain' });
127
147
  });
128
148
  });
129
149
 
@@ -172,24 +192,22 @@ describe('message queue runtime', () => {
172
192
  it('delivers the head of the queue when the session goes idle, in order', async () => {
173
193
  const { runtime, openCode, emit, promptSent, broadcasts } = createRuntime();
174
194
  runtime.start();
175
- openCode.state.statuses = { [SESSION]: { type: 'busy' } };
195
+ openCode.state.active = { [SESSION]: { type: 'running' } };
176
196
 
177
197
  await runtime.enqueue(SESSION, DIRECTORY, item({ content: 'first', text: 'first' }));
178
198
  await runtime.enqueue(SESSION, DIRECTORY, item({ content: 'second', text: 'second' }));
179
199
  await settle();
180
200
  expect(openCode.state.sent).toHaveLength(0);
181
201
 
182
- openCode.state.statuses = {};
202
+ openCode.state.active = {};
183
203
  emit({ type: 'session.status', properties: { sessionID: SESSION, status: { type: 'idle' } } });
184
204
  await settle();
185
205
 
186
206
  expect(openCode.state.sent).toHaveLength(1);
187
- expect(openCode.state.sent[0].path).toBe(`/session/${SESSION}/prompt_async`);
188
- expect(openCode.state.sent[0].body).toEqual({
189
- model: { providerID: 'anthropic', modelID: 'claude' },
190
- agent: 'build',
191
- parts: [{ type: 'text', text: 'first' }],
192
- });
207
+ expect(openCode.state.sent[0].path).toBe(`/api/session/${SESSION}/prompt`);
208
+ // v2 selects model and agent on the session, so the prompt carries text
209
+ // and attachments only.
210
+ expect(openCode.state.sent[0].body).toEqual({ text: 'first' });
193
211
  expect(promptSent).toEqual([SESSION]);
194
212
  expect(runtime.sessionSnapshot(SESSION).items.map((entry) => entry.content)).toEqual(['second']);
195
213
  // Clients learned about the in-flight item and then the removal.
@@ -206,11 +224,32 @@ describe('message queue runtime', () => {
206
224
  expect(runtime.sessionSnapshot(SESSION).items).toEqual([]);
207
225
  });
208
226
 
227
+ it('waits for a background subagent before sending the queued message', async () => {
228
+ const { runtime, openCode, emit } = createRuntime();
229
+ runtime.start();
230
+ // The parent paused: it is idle while its background subagent works.
231
+ openCode.state.children = { [SESSION]: ['ses_child'] };
232
+ openCode.state.active = { ses_child: { type: 'running' } };
233
+
234
+ await runtime.enqueue(SESSION, DIRECTORY, item({ content: 'next', text: 'next' }));
235
+ emit({ type: 'session.status', properties: { sessionID: SESSION, status: { type: 'idle' } } });
236
+ await settle();
237
+ expect(openCode.state.sent).toHaveLength(0);
238
+
239
+ // The subagent finished and OpenCode ran the parent again with its result.
240
+ openCode.state.active = {};
241
+ emit({ type: 'session.status', properties: { sessionID: SESSION, status: { type: 'busy' } } });
242
+ emit({ type: 'session.status', properties: { sessionID: SESSION, status: { type: 'idle' } } });
243
+ await settle();
244
+ expect(openCode.state.sent).toHaveLength(1);
245
+ expect(openCode.state.sent[0].body).toEqual({ text: 'next' });
246
+ });
247
+
209
248
  it('does not send into a running turn even when the status event says idle', async () => {
210
249
  const { runtime, openCode, emit } = createRuntime({ now: () => 10_000 });
211
250
  runtime.start();
212
251
  // Live unfinished turn: created after this runtime started.
213
- openCode.state.tail = [{ info: { role: 'assistant', time: { created: 10_001 } } }];
252
+ openCode.state.tail = [{ type: 'assistant', time: { created: 10_001 } }];
214
253
  await runtime.enqueue(SESSION, DIRECTORY, item());
215
254
  emit({ type: 'session.status', properties: { sessionID: SESSION, status: { type: 'idle' } } });
216
255
  await settle();
@@ -218,7 +257,7 @@ describe('message queue runtime', () => {
218
257
 
219
258
  // The reply completes: that alone drains the queue (a missed idle event
220
259
  // must not strand it).
221
- openCode.state.tail = [{ info: { role: 'assistant', time: { created: 10_001, completed: 10_002 } } }];
260
+ openCode.state.tail = [{ type: 'assistant', time: { created: 10_001, completed: 10_002 } }];
222
261
  emit({ type: 'message.updated', properties: { info: { role: 'assistant', sessionID: SESSION, time: { created: 10_001, completed: 10_002 } } } });
223
262
  await settle();
224
263
  expect(openCode.state.sent).toHaveLength(1);
@@ -230,19 +269,19 @@ describe('message queue runtime', () => {
230
269
  // Assistant reply interrupted by a server restart: unfinished, but older
231
270
  // than this runtime — no completion event will ever arrive for it, so it
232
271
  // must not block a restored queue forever.
233
- openCode.state.tail = [{ info: { role: 'assistant', time: { created: 1 } } }];
272
+ openCode.state.tail = [{ type: 'assistant', time: { created: 1 } }];
234
273
  await runtime.enqueue(SESSION, DIRECTORY, item());
235
274
  emit({ type: 'session.status', properties: { sessionID: SESSION, status: { type: 'idle' } } });
236
275
  await settle();
237
276
  expect(openCode.state.sent).toHaveLength(1);
238
- expect(openCode.state.sent[0].path).toBe(`/session/${SESSION}/prompt_async`);
277
+ expect(openCode.state.sent[0].path).toBe(`/api/session/${SESSION}/prompt`);
239
278
  });
240
279
 
241
280
  it('treats an unreachable OpenCode as unknown, not idle', async () => {
242
281
  const { runtime, openCode, emit } = createRuntime({ retryDelayMs: () => 10 });
243
282
  runtime.start();
244
283
  await runtime.enqueue(SESSION, DIRECTORY, item());
245
- openCode.state.failNext = /\/session\/status$/;
284
+ openCode.state.failNext = /\/api\/session\/active$/;
246
285
  emit({ type: 'session.status', properties: { sessionID: SESSION, status: { type: 'idle' } } });
247
286
  await settle(5);
248
287
  expect(openCode.state.sent).toHaveLength(0);
@@ -255,7 +294,7 @@ describe('message queue runtime', () => {
255
294
  const { runtime, openCode, emit, broadcasts } = createRuntime({ retryDelayMs: () => 20 });
256
295
  runtime.start();
257
296
  await runtime.enqueue(SESSION, DIRECTORY, item());
258
- openCode.state.failNext = /prompt_async$/;
297
+ openCode.state.failNext = /\/prompt$/;
259
298
  emit({ type: 'session.status', properties: { sessionID: SESSION, status: { type: 'idle' } } });
260
299
  await settle(10);
261
300
  expect(openCode.state.sent).toHaveLength(0);
@@ -271,8 +310,10 @@ describe('message queue runtime', () => {
271
310
  const { runtime, openCode, emit } = createRuntime();
272
311
  runtime.start();
273
312
  await runtime.enqueue(SESSION, DIRECTORY, item());
274
- emit({ type: 'message.updated', properties: { info: { role: 'assistant', sessionID: SESSION, error: { name: 'MessageAbortedError' } } } });
313
+ // v2 reports a user abort as `session.execution.interrupted`, which the
314
+ // translator turns into an aborted `session.idle`.
275
315
  emit({ type: 'session.status', properties: { sessionID: SESSION, status: { type: 'idle' } } });
316
+ emit({ type: 'session.idle', properties: { sessionID: SESSION, aborted: true, reason: 'user' } });
276
317
  await settle(10);
277
318
  expect(openCode.state.sent).toHaveLength(0);
278
319
  await settle(80);
@@ -297,7 +338,7 @@ describe('message queue runtime', () => {
297
338
  const dataDir = makeDataDir();
298
339
  const first = createRuntime({ dataDir });
299
340
  first.runtime.start();
300
- first.openCode.state.statuses = { [SESSION]: { type: 'busy' } };
341
+ first.openCode.state.active = { [SESSION]: { type: 'running' } };
301
342
  await first.runtime.enqueue(SESSION, DIRECTORY, item({ content: 'persisted', text: 'persisted', contextPreview: 'Saved context preview' }));
302
343
  await first.runtime.flush();
303
344
  first.runtime.stop();
@@ -310,7 +351,7 @@ describe('message queue runtime', () => {
310
351
  second.connect();
311
352
  await settle();
312
353
  expect(second.openCode.state.sent).toHaveLength(1);
313
- expect(second.openCode.state.sent[0].body.parts).toEqual([{ type: 'text', text: 'persisted' }]);
354
+ expect(second.openCode.state.sent[0].body).toEqual({ text: 'persisted' });
314
355
  });
315
356
 
316
357
  it('moves an unreadable queue file aside instead of treating it as empty', async () => {
@@ -326,9 +367,13 @@ describe('message queue runtime', () => {
326
367
  const { runtime, openCode, emit } = createRuntime();
327
368
  runtime.start();
328
369
  let release;
329
- // status map, message tail, then the prompt itself (held open until released)
330
- openCode.fetchImpl.mockImplementationOnce(async () => Response.json({}))
331
- .mockImplementationOnce(async () => Response.json([]))
370
+ // active map, message tail, the subagent check (active map, children),
371
+ // model switch, then the prompt itself (held open until released)
372
+ openCode.fetchImpl.mockImplementationOnce(async () => Response.json({ location: {}, data: {} }))
373
+ .mockImplementationOnce(async () => Response.json({ data: [], cursor: {} }))
374
+ .mockImplementationOnce(async () => Response.json({ data: {} }))
375
+ .mockImplementationOnce(async () => Response.json({ data: [], cursor: {} }))
376
+ .mockImplementationOnce(async () => new Response(null, { status: 204 }))
332
377
  .mockImplementationOnce(() => new Promise((resolve) => { release = () => resolve(new Response(null, { status: 204 })); }));
333
378
  const { itemId } = await runtime.enqueue(SESSION, DIRECTORY, item());
334
379
  emit({ type: 'session.status', properties: { sessionID: SESSION, status: { type: 'idle' } } });
@@ -398,7 +443,7 @@ describe('message queue runtime', () => {
398
443
  const { runtime, emit, broadcasts, openCode } = createRuntime();
399
444
  runtime.start();
400
445
  await runtime.enqueue(SESSION, DIRECTORY, item());
401
- openCode.state.statuses = {};
446
+ openCode.state.active = {};
402
447
  emit({ type: 'session.status', properties: { sessionID: SESSION, status: { type: 'idle' } } });
403
448
  await settle();
404
449
 
@@ -434,11 +479,12 @@ describe('message queue runtime', () => {
434
479
  emit({ type: 'session.status', properties: { sessionID: SESSION, status: { type: 'idle' } } });
435
480
  await settle();
436
481
  expect(openCode.state.sent).toHaveLength(1);
437
- expect(openCode.state.sent[0].path).toBe(`/session/${SESSION}/command`);
438
- expect(openCode.state.sent[0].body).toEqual({ command: 'review', arguments: 'src', model: 'p/m', agent: 'build', variant: 'max' });
482
+ expect(openCode.state.sent[0].path).toBe(`/api/session/${SESSION}/command`);
483
+ // v2's command route takes `text`, and the selection lives on the session.
484
+ expect(openCode.state.sent[0].body).toEqual({ name: 'review', text: 'src' });
439
485
  });
440
486
 
441
- it('delivers captured context as synthetic parts, instructions first, before project knowledge', async () => {
487
+ it('delivers captured context as synthetic messages, instructions first, before project knowledge', async () => {
442
488
  const knowledge = {
443
489
  resolvePendingForSession: async () => ({ text: 'pinned notes', signature: 'sig-1' }),
444
490
  recordDelivered: async () => {},
@@ -457,16 +503,29 @@ describe('message queue runtime', () => {
457
503
  }));
458
504
  emit({ type: 'session.status', properties: { sessionID: SESSION, status: { type: 'idle' } } });
459
505
  await settle();
460
- expect(openCode.state.sent[0].body.parts).toEqual([
461
- { type: 'text', text: 'follow up' },
462
- { type: 'file', mime: 'text/plain', filename: 'f.txt', url: 'data:text/plain,hi' },
463
- { type: 'text', text: 'how to read it', synthetic: true },
464
- { type: 'text', text: 'the diff', synthetic: true, metadata },
465
- { type: 'text', text: 'conflict payload', synthetic: true },
466
- { type: 'text', text: 'use the skill', synthetic: true },
467
- { type: 'text', text: 'pinned notes', synthetic: true },
468
- { type: 'agent', name: 'reviewer' },
506
+
507
+ // v2 has no inline synthetic parts: everything attached to the message is
508
+ // admitted as its own synthetic message, before the prompt.
509
+ expect(openCode.state.sent.map((entry) => entry.path)).toEqual([
510
+ `/api/session/${SESSION}/synthetic`,
511
+ `/api/session/${SESSION}/synthetic`,
512
+ `/api/session/${SESSION}/synthetic`,
513
+ `/api/session/${SESSION}/synthetic`,
514
+ `/api/session/${SESSION}/synthetic`,
515
+ `/api/session/${SESSION}/prompt`,
516
+ ]);
517
+ expect(openCode.state.sent.slice(0, 5).map((entry) => entry.body)).toEqual([
518
+ { text: 'how to read it', resume: false },
519
+ { text: 'the diff', resume: false, metadata },
520
+ { text: 'conflict payload', resume: false },
521
+ { text: 'use the skill', resume: false },
522
+ { text: 'pinned notes', resume: false },
469
523
  ]);
524
+ expect(openCode.state.sent.at(-1).body).toEqual({
525
+ text: 'follow up',
526
+ files: [{ uri: 'data:text/plain,hi', name: 'f.txt' }],
527
+ agents: [{ name: 'reviewer' }],
528
+ });
470
529
  });
471
530
 
472
531
  it('keeps files on the command route, which is all that route accepts', async () => {
@@ -480,17 +539,21 @@ describe('message queue runtime', () => {
480
539
  }));
481
540
  emit({ type: 'session.status', properties: { sessionID: SESSION, status: { type: 'idle' } } });
482
541
  await settle();
483
- expect(openCode.state.sent[0].path).toBe(`/session/${SESSION}/command`);
484
- expect(openCode.state.sent[0].body.parts).toEqual([{ type: 'file', mime: 'text/plain', filename: 'f.txt', url: 'data:text/plain,hi' }]);
542
+ expect(openCode.state.sent[0].path).toBe(`/api/session/${SESSION}/command`);
543
+ expect(openCode.state.sent[0].body).toEqual({
544
+ name: 'review',
545
+ text: '',
546
+ files: [{ uri: 'data:text/plain,hi', name: 'f.txt' }],
547
+ });
485
548
  });
486
549
 
487
- it('sends a command queued with context as its expanded prompt, context included', async () => {
488
- // The command route rejects text parts, so a command with captured
489
- // context takes the prompt route with the template expanded, exactly as
490
- // the composer does.
550
+ it('admits captured context as synthetic messages and still runs a queued command through the command route', async () => {
551
+ // The command route takes attachments only, so the context goes ahead as
552
+ // synthetic messages; the command itself keeps its route, because sending
553
+ // "/review ..." as a prompt would skip the template OpenCode expands there.
491
554
  const { runtime, openCode, emit } = createRuntime();
492
555
  runtime.start();
493
- openCode.state.commands = [{ name: 'review', source: 'command', template: 'Review $1 with focus on $2' }];
556
+ openCode.state.commands = [{ name: 'review', description: 'Review' }];
494
557
  const metadata = { openchamberContext: { kind: 'chat-quote', quote: 'q', text: 'why?' } };
495
558
  await runtime.enqueue(SESSION, DIRECTORY, item({
496
559
  content: '/review src "error handling"',
@@ -499,30 +562,50 @@ describe('message queue runtime', () => {
499
562
  }));
500
563
  emit({ type: 'session.status', properties: { sessionID: SESSION, status: { type: 'idle' } } });
501
564
  await settle();
502
- expect(openCode.state.sent[0].path).toBe(`/session/${SESSION}/prompt_async`);
503
- expect(openCode.state.sent[0].body.parts).toEqual([
504
- { type: 'text', text: 'Review src with focus on error handling' },
505
- { type: 'text', text: 'quoted', synthetic: true, metadata },
565
+
566
+ expect(openCode.state.sent.map((entry) => entry.path)).toEqual([
567
+ `/api/session/${SESSION}/synthetic`,
568
+ `/api/session/${SESSION}/command`,
506
569
  ]);
570
+ expect(openCode.state.sent[0].body).toEqual({ text: 'quoted', resume: false, metadata });
571
+ expect(openCode.state.sent[1].body).toEqual({ name: 'review', text: 'src "error handling"' });
507
572
  });
508
573
 
509
- it('sends a skill queued with context as an explicit invocation, context included', async () => {
574
+ it('delivers pending project knowledge ahead of a queued command and records it', async () => {
575
+ const recorded = [];
576
+ const knowledge = {
577
+ resolvePendingForSession: async () => ({ text: 'pinned notes', signature: 'sig-1' }),
578
+ recordDelivered: async (sessionId, directory, signature) => { recorded.push({ sessionId, directory, signature }); },
579
+ };
580
+ const { runtime, openCode, emit } = createRuntime({ knowledge });
581
+ runtime.start();
582
+ openCode.state.commands = [{ name: 'review' }];
583
+ await runtime.enqueue(SESSION, DIRECTORY, item({ content: '/review', text: '/review' }));
584
+ emit({ type: 'session.status', properties: { sessionID: SESSION, status: { type: 'idle' } } });
585
+ await settle();
586
+
587
+ expect(openCode.state.sent.map((entry) => entry.path)).toEqual([
588
+ `/api/session/${SESSION}/synthetic`,
589
+ `/api/session/${SESSION}/command`,
590
+ ]);
591
+ expect(openCode.state.sent[0].body).toEqual({ text: 'pinned notes', resume: false });
592
+ expect(recorded).toEqual([{ sessionId: SESSION, directory: DIRECTORY, signature: 'sig-1' }]);
593
+ });
594
+
595
+ it('admits nothing when the command lookup fails, so a retry cannot duplicate the context', async () => {
510
596
  const { runtime, openCode, emit } = createRuntime();
511
597
  runtime.start();
512
- openCode.state.commands = [{ name: 'grill', source: 'skill', template: 'skill body' }];
598
+ openCode.state.failNext = /^\/api\/command$/;
513
599
  await runtime.enqueue(SESSION, DIRECTORY, item({
514
- content: '/grill auth',
515
- text: '/grill auth',
516
- context: [{ kind: 'synthetic', text: 'focus on tests' }],
600
+ content: '/review',
601
+ text: '/review',
602
+ context: [{ kind: 'context', text: 'quoted', metadata: {} }],
517
603
  }));
518
604
  emit({ type: 'session.status', properties: { sessionID: SESSION, status: { type: 'idle' } } });
519
605
  await settle();
520
- expect(openCode.state.sent[0].path).toBe(`/session/${SESSION}/prompt_async`);
521
- expect(openCode.state.sent[0].body.parts).toEqual([
522
- { type: 'text', text: '/grill auth' },
523
- { type: 'text', text: 'focus on tests', synthetic: true },
524
- { type: 'text', text: 'The user explicitly invoked the grill skill. Use the corresponding skill tool to handle this request.', synthetic: true },
525
- ]);
606
+
607
+ expect(openCode.state.sent).toEqual([]);
608
+ expect(runtime.sessionSnapshot(SESSION).items).toHaveLength(1);
526
609
  });
527
610
 
528
611
  it('keeps captured context out of snapshots and broadcasts, and hands it back on take', async () => {
@@ -548,12 +631,15 @@ describe('message queue runtime', () => {
548
631
  await runtime.enqueue(SESSION, DIRECTORY, item({ agentMention: 'reviewer', attachments: [{ id: 'a', filename: 'f.txt', mimeType: 'text/plain', size: 1, source: 'local', dataUrl: 'data:text/plain,hi' }] }));
549
632
  emit({ type: 'session.status', properties: { sessionID: SESSION, status: { type: 'idle' } } });
550
633
  await settle();
551
- expect(openCode.state.sent[0].body.parts).toEqual([
552
- { type: 'text', text: 'follow up' },
553
- { type: 'file', mime: 'text/plain', filename: 'f.txt', url: 'data:text/plain,hi' },
554
- { type: 'text', text: 'pinned notes', synthetic: true },
555
- { type: 'agent', name: 'reviewer' },
556
- ]);
634
+ // Knowledge is admitted as a synthetic message ahead of the prompt.
635
+ expect(openCode.state.sent[0].path).toBe(`/api/session/${SESSION}/synthetic`);
636
+ expect(openCode.state.sent[0].body).toEqual({ text: 'pinned notes', resume: false });
637
+ expect(openCode.state.sent[1].path).toBe(`/api/session/${SESSION}/prompt`);
638
+ expect(openCode.state.sent[1].body).toEqual({
639
+ text: 'follow up',
640
+ files: [{ uri: 'data:text/plain,hi', name: 'f.txt' }],
641
+ agents: [{ name: 'reviewer' }],
642
+ });
557
643
  expect(recorded).toEqual([{ sessionId: SESSION, directory: DIRECTORY, signature: 'sig-1' }]);
558
644
  });
559
645
  });
@@ -47,6 +47,7 @@ This module provides notification message preparation utilities for the web serv
47
47
  - Owns:
48
48
  - completion/error/question/permission trigger routing; permission suppression consults the authoritative permission-auto-accept runtime
49
49
  - session parent cache for subtask suppression
50
+ - no ready notification on a `session.idle` while a subagent of that session is still running (a background-subagent pause; OpenCode runs the parent again with the result and its next idle announces). The check comes from `../opencode/session-activity.js`; when it cannot be made, the idle announces as before
50
51
  - template resolution and fallback behavior
51
52
  - native notification fanout and web push payload fanout
52
53
  - push suppression while any fresh UI visibility heartbeat reports a focused client
@@ -136,3 +137,30 @@ The `settings` parameter for `prepareNotificationLastMessage` supports `maxLastM
136
137
  ### Testing
137
138
  - Run `bun run type-check`, `bun run lint`, and `bun run build` before finalizing changes.
138
139
  - Unit tests should cover truncation behavior and edge cases (empty strings, invalid inputs).
140
+
141
+ ## Browser event delivery
142
+
143
+ Web notifications subscribe to the shared `/api/openchamber/events` control
144
+ stream. The emitter sends each notification there and through the existing
145
+ global broadcaster. The broadcaster owns the main WebSocket and the legacy
146
+ `/api/notifications/stream` endpoint, which remains available to older clients.
147
+ Do not call both global and control broadcasters for one notification: they
148
+ share the WebSocket client set and would send each frame twice.
149
+
150
+ The web hook preserves `tag`, `sessionId`, `kind`, and the remaining notification
151
+ fields so the web notification API recognizes duplicate control-SSE and main-WS
152
+ delivery. Its existing five-second claim suppresses duplicate native alerts.
153
+ Native alerts remain best-effort live events, with no historical replay or
154
+ synthetic catch-up alerts. Authoritative session attention is owned by sync.
155
+
156
+ This removes one persistent HTTP connection per browser tab in both main-WS and
157
+ main-SSE modes. Two tabs use two control SSE connections, or four connections
158
+ when their main event pipelines also use SSE. It does not make HTTP/1.1 support
159
+ an unlimited number of tabs. The notification hook retains the app's background
160
+ work enablement, settings, focus, and runtime gates; it no longer opens a second
161
+ connection or switches sources when the main pipeline changes transport.
162
+
163
+ The control stream retains its browser-control capability declaration, runtime
164
+ switch cleanup, stale-source rejection, heartbeat, and reconnect-ready events.
165
+ Electron uses its native notification path; VS Code does not subscribe to this
166
+ server-only stream. Mobile shells retain their existing push behavior.
@@ -0,0 +1,68 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { createServer } from 'node:http';
3
+ import express from 'express';
4
+ import { createUiAuth } from '../ui-auth/ui-auth.js';
5
+ import { registerScheduledTaskRoutes } from '../scheduled-tasks/routes.js';
6
+ import { createNotificationEmitterRuntime } from './emitter-runtime.js';
7
+
8
+ // Exercise the existing browser URL-token gate and the real SSE route, not an
9
+ // unauthenticated mock endpoint. Notification consolidation adds no auth bypass.
10
+ describe('notifications over the authenticated control stream', () => {
11
+ it('rejects anonymous readers and delivers to a scoped authenticated stream', async () => {
12
+ const auth = createUiAuth({
13
+ requireClientAuth: true,
14
+ clientAuthController: {
15
+ authenticateBearerToken: async (token) => token === 'test-client' ? { ok: true, clientId: 'fixture' } : null,
16
+ },
17
+ });
18
+ const clients = new Set();
19
+ const emitter = createNotificationEmitterRuntime({
20
+ process,
21
+ getDesktopNotifyEnabled: () => false,
22
+ desktopNotifyPrefix: '',
23
+ getUiNotificationClients: () => new Set(),
24
+ getOpenChamberEventClients: () => clients,
25
+ });
26
+ const app = express();
27
+ app.use(express.json());
28
+ app.post('/auth/url-token', auth.handleUrlAuthToken);
29
+ app.use('/api', auth.requireAuth);
30
+ registerScheduledTaskRoutes(app, {
31
+ getOpenChamberEventClients: () => clients,
32
+ writeSseEvent: emitter.writeSseEvent,
33
+ });
34
+ const server = createServer(app);
35
+ await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
36
+ const base = `http://127.0.0.1:${server.address().port}`;
37
+ const abort = new AbortController();
38
+ try {
39
+ const denied = await fetch(`${base}/api/openchamber/events`);
40
+ expect(denied.status).toBe(401);
41
+ expect(clients.size).toBe(0);
42
+ const tokenResponse = await fetch(`${base}/auth/url-token`, {
43
+ method: 'POST', headers: { Authorization: 'Bearer test-client' },
44
+ });
45
+ expect(tokenResponse.status).toBe(200);
46
+ const { token } = await tokenResponse.json();
47
+ expect(token).toBeTruthy();
48
+ const response = await fetch(`${base}/api/openchamber/events?browser=1&oc_url_token=${encodeURIComponent(token)}`, { signal: abort.signal });
49
+ expect(response.status).toBe(200);
50
+ expect(response.headers.get('content-type')).toContain('text/event-stream');
51
+ const reader = response.body.getReader();
52
+ const ready = new TextDecoder().decode((await reader.read()).value);
53
+ expect(ready).toContain('openchamber:event-stream-ready');
54
+ expect(clients.size).toBe(1);
55
+ expect([...clients][0].openchamberBrowserCapable).toBe(true);
56
+ emitter.broadcastUiNotification({ title: 'Done', sessionId: 's1', kind: 'complete' });
57
+ const notification = new TextDecoder().decode((await reader.read()).value);
58
+ expect(notification).toContain('"type":"openchamber:notification"');
59
+ expect(notification).toContain('"sessionId":"s1"');
60
+ await reader.cancel();
61
+ } finally {
62
+ abort.abort();
63
+ server.closeAllConnections();
64
+ await new Promise((resolve) => server.close(resolve));
65
+ auth.dispose();
66
+ }
67
+ });
68
+ });
@@ -4,6 +4,7 @@ export const createNotificationEmitterRuntime = (dependencies) => {
4
4
  getDesktopNotifyEnabled,
5
5
  desktopNotifyPrefix,
6
6
  getUiNotificationClients,
7
+ getOpenChamberEventClients = () => new Set(),
7
8
  getBroadcastGlobalUiEvent,
8
9
  // Optional: in-process desktop shells (Electron main) inject a callback so
9
10
  // notifications are delivered as a direct function call instead of a stdout
@@ -78,6 +79,20 @@ export const createNotificationEmitterRuntime = (dependencies) => {
78
79
  },
79
80
  };
80
81
 
82
+ // New clients share the control SSE; older clients retain their dedicated
83
+ // notification SSE. The global broadcaster still owns WebSocket delivery.
84
+ const controlClients = getOpenChamberEventClients();
85
+ if (controlClients.size > 0) {
86
+ const serializedPayload = JSON.stringify(syntheticPayload);
87
+ for (const client of controlClients) {
88
+ try {
89
+ writeSseEvent(client, syntheticPayload, serializedPayload);
90
+ } catch {
91
+ // One disconnected control client must not block other transports.
92
+ }
93
+ }
94
+ }
95
+
81
96
  const broadcastGlobalUiEvent = typeof getBroadcastGlobalUiEvent === 'function'
82
97
  ? getBroadcastGlobalUiEvent()
83
98
  : null;
@@ -45,3 +45,35 @@ describe('notification emitter runtime', () => {
45
45
  });
46
46
  });
47
47
  });
48
+
49
+
50
+ describe('shared control notification delivery', () => {
51
+ it('writes once to control SSE and once to the legacy/global broadcaster', () => {
52
+ const control = { write: vi.fn() };
53
+ const failed = { write: () => { throw new Error('closed'); } };
54
+ const broadcastGlobalUiEvent = vi.fn();
55
+ const runtime = createRuntime({
56
+ getOpenChamberEventClients: () => new Set([failed, control]),
57
+ getBroadcastGlobalUiEvent: () => broadcastGlobalUiEvent,
58
+ });
59
+ runtime.broadcastUiNotification({ title: 'Done', sessionId: 's1', kind: 'complete' });
60
+ expect(control.write).toHaveBeenCalledTimes(1);
61
+ expect(broadcastGlobalUiEvent).toHaveBeenCalledTimes(1);
62
+ const envelope = broadcastGlobalUiEvent.mock.calls[0][0];
63
+ expect(control.write).toHaveBeenCalledWith(`data: ${JSON.stringify(envelope)}\n\n`);
64
+ expect(envelope.properties.sessionId).toBe('s1');
65
+ });
66
+
67
+ it('preserves legacy notification SSE without a global broadcaster', () => {
68
+ const control = { write: vi.fn() };
69
+ const legacy = { write: vi.fn() };
70
+ const runtime = createRuntime({
71
+ getOpenChamberEventClients: () => new Set([control]),
72
+ getUiNotificationClients: () => new Set([legacy]),
73
+ });
74
+ runtime.broadcastUiNotification({ title: 'Done' });
75
+ expect(control.write).toHaveBeenCalledTimes(1);
76
+ expect(legacy.write).toHaveBeenCalledTimes(1);
77
+ expect(control.write.mock.calls).toEqual(legacy.write.mock.calls);
78
+ });
79
+ });