@vantran-se/openchamber-web 2.0.3 → 2.0.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (265) hide show
  1. package/README.md +20 -28
  2. package/bin/cli.js +3 -1
  3. package/bin/cli.test.js +13 -4
  4. package/bin/lib/DOCUMENTATION.md +4 -1
  5. package/bin/lib/commands-serve.js +9 -0
  6. package/dist/assets/{CodeMirrorCommentWidgets-CWhdh0wf.js → CodeMirrorCommentWidgets-DCAQTLMM.js} +1 -1
  7. package/dist/assets/DiffView-uzNpHBlV.js +4 -0
  8. package/dist/assets/FilesView-Bkq0sjNx.js +17 -0
  9. package/dist/assets/{GitView-DKIjpnu4.js → GitView-BmibZC7n.js} +4 -4
  10. package/dist/assets/{GuestAttachDialog-BIa9jCfL.js → GuestAttachDialog-D7TJG1rt.js} +1 -1
  11. package/dist/assets/GuestSurfacePane-PAcYaFzP.js +1 -0
  12. package/dist/assets/{JsonTreeView-CtI1Mqwo.js → JsonTreeView-BNT2yUVu.js} +1 -1
  13. package/dist/assets/{LinearIssuesView-BD2zLu-r.js → LinearIssuesView-D3Azksju.js} +1 -1
  14. package/dist/assets/MarkdownImageGallery-BGY6tuiK.js +1 -0
  15. package/dist/assets/MarkdownRendererImpl-DJmhQ37f.js +12 -0
  16. package/dist/assets/{OnboardingScreen-B8TxloXZ.js → OnboardingScreen-D_41H2qC.js} +1 -1
  17. package/dist/assets/{PatchDiff--Civs1xY.js → PatchDiff-C0RSqa9W.js} +1 -1
  18. package/dist/assets/{PierreDiffViewer-BlDmw3-A.js → PierreDiffViewer-BBE7tTY2.js} +3 -3
  19. package/dist/assets/{PlanView-DyH2SyCP.js → PlanView-Bqc-xuDe.js} +4 -4
  20. package/dist/assets/PluginPane-DQ0k41Q-.js +3 -0
  21. package/dist/assets/SettingsView-BAiJU5hJ.js +30 -0
  22. package/dist/assets/{SettingsWindow-y9WpZqK6.js → SettingsWindow-DuC4ecLw.js} +1 -1
  23. package/dist/assets/{SubmoduleDiffSummary-DeFmhPFZ.js → SubmoduleDiffSummary-0HgmcMXz.js} +1 -1
  24. package/dist/assets/{ToolOutputDialog-8DmF7_PZ.js → ToolOutputDialog-DdHkQJEE.js} +6 -6
  25. package/dist/assets/{ToolPartDiffPreview-Ca15DKE1.js → ToolPartDiffPreview-DWdN0r5K.js} +2 -2
  26. package/dist/assets/{VirtualizedFileDiff-BfEBad09.js → VirtualizedFileDiff-ClDezf2d.js} +1 -1
  27. package/dist/assets/{WalkthroughView-Cj3K8Xz6.js → WalkthroughView-BJicp0RC.js} +2 -2
  28. package/dist/assets/{appThemeRegistry-DAdawJZn.js → appThemeRegistry-CiuyzfSW.js} +1 -1
  29. package/dist/assets/changesTree-CePHbFcj.js +1 -0
  30. package/dist/assets/commitAndPush-DTKb6IwT.js +1 -0
  31. package/dist/assets/{computeVirtualFileMetrics-RF-KjfUH.js → computeVirtualFileMetrics-kM_zJNMt.js} +2 -2
  32. package/dist/assets/de-BMnfXm-G.js +10 -0
  33. package/dist/assets/es-DKEvCCL6.js +16 -0
  34. package/dist/assets/fr-D9fgE2xE.js +14 -0
  35. package/dist/assets/index-DUke1nme.css +1 -0
  36. package/dist/assets/{index-CL7ZQ94-.js → index-DWB8bR_g.js} +1 -1
  37. package/dist/assets/index-Di6zUe5h.js +41 -0
  38. package/dist/assets/{isDiffPlainText-D2yJ9fO6.js → isDiffPlainText-DOoIsGqs.js} +1 -1
  39. package/dist/assets/{isFilePlainText-BwMEq1Ey.js → isFilePlainText-bt5VHvcd.js} +1 -1
  40. package/dist/assets/ja-BEXad-KW.js +16 -0
  41. package/dist/assets/ko-DGrQ0lEe.js +16 -0
  42. package/dist/assets/{main-quLSA7jc.js → main-BpLQbZC2.js} +2 -2
  43. package/dist/assets/main-CI9DtHJv.js +896 -0
  44. package/dist/assets/{markdown-shiki.worker-CahLvHIq.js → markdown-shiki.worker-Bg8SoAJJ.js} +8 -5
  45. package/dist/assets/markdownCore-DK2KK5RW.js +7 -0
  46. package/dist/assets/{miniChat-D-lJJibr.js → miniChat-CIWaR8wG.js} +2 -2
  47. package/dist/assets/{mobile-VTtphbW6.js → mobile-CnWKEYIP.js} +2 -2
  48. package/dist/assets/patchFileDiff-Cqn0C0az.js +13 -0
  49. package/dist/assets/pl-BlhkmR5b.js +16 -0
  50. package/dist/assets/pt-BR-Bs96Ew9I.js +16 -0
  51. package/dist/assets/{renderElectronMiniChatApp-DF7ch5eM.js → renderElectronMiniChatApp-D9t0-9nV.js} +2 -2
  52. package/dist/assets/renderMobileApp-CW7fVs_s.js +25 -0
  53. package/dist/assets/runtimeEndpointReset-D0PcCb3K.js +27 -0
  54. package/dist/assets/scheduledTasksApi-Dahv3ws0.js +1 -0
  55. package/dist/assets/{shikiHighlight-_HeCCHx1.js → shikiHighlight-l-MbaNS4.js} +1 -1
  56. package/dist/assets/{textMateThemeFromAppTheme-C95XdFGj.js → textMateThemeFromAppTheme-C-kGcOpD.js} +1 -1
  57. package/dist/assets/tr-CehUK1M9.js +7 -0
  58. package/dist/assets/uk-Bno5WGNz.js +16 -0
  59. package/dist/assets/{useAppFontEffects-ZM9ij19-.js → useAppFontEffects-DDjuwVqi.js} +160 -156
  60. package/dist/assets/{useInlineCommentController-CkfxMJPy.js → useInlineCommentController-Clg8EO3A.js} +2 -2
  61. package/dist/assets/{useRootScrollLock-BtiAC1fj.js → useRootScrollLock-nDcRrgJ1.js} +2 -2
  62. package/dist/assets/{vendor-codemirror-language-data-U-lXzXNK.js → vendor-codemirror-language-data-oKF0MAwd.js} +2 -2
  63. package/dist/assets/vendor-opencode-client-DSZl7jSi.js +12 -0
  64. package/dist/assets/vendor-zod-BSoePkpP.js +39 -0
  65. package/dist/assets/{web-components-C583Pmfm.js → web-components-Bc1WPd4i.js} +1 -1
  66. package/dist/assets/zh-CN-DnAweIWo.js +16 -0
  67. package/dist/assets/zh-TW-BbBBK25p.js +16 -0
  68. package/dist/index.html +5 -5
  69. package/dist/mini-chat.html +5 -5
  70. package/dist/mobile.html +5 -5
  71. package/package.json +3 -3
  72. package/server/index.d.ts +2 -0
  73. package/server/index.js +196 -52
  74. package/server/lib/agent-tool/DOCUMENTATION.md +5 -0
  75. package/server/lib/agent-tool/runtime.js +89 -51
  76. package/server/lib/agent-tool/runtime.test.js +17 -0
  77. package/server/lib/browser-control/DOCUMENTATION.md +14 -2
  78. package/server/lib/event-stream/DOCUMENTATION.md +2 -2
  79. package/server/lib/event-stream/global-hub.js +45 -7
  80. package/server/lib/event-stream/global-hub.test.js +128 -2
  81. package/server/lib/event-stream/global-ws-bridge.js +3 -1
  82. package/server/lib/event-stream/rebind.test.js +1 -0
  83. package/server/lib/event-stream/runtime.js +1 -2
  84. package/server/lib/event-stream/upstream-reader.js +3 -2
  85. package/server/lib/git/DOCUMENTATION.md +1 -1
  86. package/server/lib/git/service.js +22 -0
  87. package/server/lib/git/service.test.js +8 -0
  88. package/server/lib/guests/DOCUMENTATION.md +4 -3
  89. package/server/lib/guests/catalog.js +19 -1
  90. package/server/lib/guests/catalog.test.js +22 -0
  91. package/server/lib/guests/html-styles.js +5 -3
  92. package/server/lib/guests/html-styles.test.js +3 -1
  93. package/server/lib/guests/routes.js +11 -3
  94. package/server/lib/guests/service.js +5 -0
  95. package/server/lib/guests/surface.js +55 -9
  96. package/server/lib/guests/surface.test.js +56 -6
  97. package/server/lib/notifications/DOCUMENTATION.md +10 -0
  98. package/server/lib/notifications/emit-route.js +118 -0
  99. package/server/lib/notifications/emit-route.test.js +112 -0
  100. package/server/lib/openchamber-control/actions.js +16 -2
  101. package/server/lib/openchamber-control/service.js +23 -0
  102. package/server/lib/openchamber-control/service.test.js +31 -0
  103. package/server/lib/openchamber-sessions/fork-inheritance.js +103 -0
  104. package/server/lib/openchamber-sessions/fork-inheritance.test.js +69 -0
  105. package/server/lib/openchamber-sessions/routes.js +11 -0
  106. package/server/lib/openchamber-sessions/routes.test.js +18 -0
  107. package/server/lib/opencode/DOCUMENTATION.md +23 -23
  108. package/server/lib/opencode/bootstrap-runtime.js +14 -1
  109. package/server/lib/opencode/compatibility.js +6 -3
  110. package/server/lib/opencode/compatibility.test.js +8 -0
  111. package/server/lib/opencode/config-entity-v2.test.js +27 -0
  112. package/server/lib/opencode/config-paths.test.js +12 -0
  113. package/server/lib/opencode/config-v2.d.ts +2 -0
  114. package/server/lib/opencode/config-v2.js +42 -0
  115. package/server/lib/opencode/config-v2.test.js +17 -0
  116. package/server/lib/opencode/core-routes.js +20 -4
  117. package/server/lib/opencode/core-routes.test.js +41 -0
  118. package/server/lib/opencode/env-runtime.js +1 -8
  119. package/server/lib/opencode/feature-routes-runtime.js +2 -1
  120. package/server/lib/opencode/lifecycle.js +156 -2
  121. package/server/lib/opencode/lifecycle.test.js +255 -1
  122. package/server/lib/opencode/managed-config-file.js +18 -6
  123. package/server/lib/opencode/managed-config-file.test.js +23 -7
  124. package/server/lib/opencode/mcp.js +7 -2
  125. package/server/lib/opencode/network-runtime.js +16 -7
  126. package/server/lib/opencode/network-runtime.test.js +29 -1
  127. package/server/lib/opencode/npm-registry-config.d.ts +1 -0
  128. package/server/lib/opencode/npm-registry-config.js +123 -0
  129. package/server/lib/opencode/npm-registry-config.test.js +167 -0
  130. package/server/lib/opencode/npm-registry.js +5 -6
  131. package/server/lib/opencode/npm-registry.test.js +30 -0
  132. package/server/lib/opencode/openchamber-routes.js +29 -11
  133. package/server/lib/opencode/openchamber-routes.test.js +18 -0
  134. package/server/lib/opencode/project-directory-runtime.js +8 -0
  135. package/server/lib/opencode/project-directory-runtime.test.js +13 -0
  136. package/server/lib/opencode/provider-source-route.test.js +59 -0
  137. package/server/lib/opencode/providers.js +3 -0
  138. package/server/lib/opencode/providers.test.js +62 -0
  139. package/server/lib/opencode/proxy.js +53 -16
  140. package/server/lib/opencode/routes-upgrade.test.js +1 -1
  141. package/server/lib/opencode/routes.js +36 -1
  142. package/server/lib/opencode/server-startup-runtime.js +25 -0
  143. package/server/lib/opencode/server-startup-runtime.test.js +67 -1
  144. package/server/lib/opencode/server-utils-runtime.js +5 -0
  145. package/server/lib/opencode/settings-helpers.js +10 -0
  146. package/server/lib/opencode/settings-helpers.test.js +2 -2
  147. package/server/lib/opencode/settings-registry.json +17 -0
  148. package/server/lib/opencode/settings-runtime.js +1 -0
  149. package/server/lib/opencode/shared-service-runtime.js +155 -0
  150. package/server/lib/opencode/shared-service-runtime.test.js +180 -0
  151. package/server/lib/opencode/shared.js +49 -4
  152. package/server/lib/opencode/shared.test.js +78 -1
  153. package/server/lib/opencode/shutdown-runtime.js +14 -5
  154. package/server/lib/opencode/shutdown-runtime.test.js +77 -2
  155. package/server/lib/opencode/skill-routes.js +28 -12
  156. package/server/lib/opencode/skill-routes.test.js +121 -1
  157. package/server/lib/opencode/startup-pipeline-runtime.js +5 -4
  158. package/server/lib/opencode/startup-pipeline-runtime.test.js +18 -7
  159. package/server/lib/opencode/upgrade-capability.js +0 -14
  160. package/server/lib/opencode/v1-migration-topup.js +21 -15
  161. package/server/lib/opencode/v1-migration-topup.test.js +14 -6
  162. package/server/lib/opencode/v2-install.d.ts +1 -0
  163. package/server/lib/opencode/v2-install.js +8 -7
  164. package/server/lib/opencode/v2-install.test.js +13 -4
  165. package/server/lib/opencode/watcher.js +3 -1
  166. package/server/lib/opencode/watcher.test.js +54 -0
  167. package/server/lib/opencode/websearch-config.js +13 -1
  168. package/server/lib/quota/providers/kimi.js +1 -1
  169. package/server/lib/realtime-proxy.js +5 -1
  170. package/server/lib/realtime-proxy.test.js +29 -0
  171. package/server/lib/relay/tunnel-host.js +4 -1
  172. package/server/lib/relay/tunnel-host.test.js +11 -0
  173. package/server/lib/session-assist/DOCUMENTATION.md +17 -5
  174. package/server/lib/session-assist/runtime.js +19 -2
  175. package/server/lib/session-assist/runtime.test.js +8 -0
  176. package/server/lib/session-goal/DOCUMENTATION.md +11 -0
  177. package/server/lib/small-model/DOCUMENTATION.md +11 -10
  178. package/server/lib/small-model/index.js +27 -32
  179. package/server/lib/small-model/index.test.js +28 -16
  180. package/server/lib/spaces/DOCUMENTATION.md +404 -32
  181. package/server/lib/spaces/code-in-bait.js +145 -6
  182. package/server/lib/spaces/code-in.js +10 -150
  183. package/server/lib/spaces/code-in.test.js +7 -83
  184. package/server/lib/spaces/code-out.js +1819 -0
  185. package/server/lib/spaces/code-out.test.js +3945 -0
  186. package/server/lib/spaces/code-transfer.js +173 -0
  187. package/server/lib/spaces/command-stream.test.js +181 -0
  188. package/server/lib/spaces/dispatcher.js +670 -0
  189. package/server/lib/spaces/dispatcher.test.js +582 -0
  190. package/server/lib/spaces/gatekeeper-channel.js +61 -7
  191. package/server/lib/spaces/gatekeeper-channel.test.js +43 -7
  192. package/server/lib/spaces/gatekeeper-program.cjs +68 -14
  193. package/server/lib/spaces/gatekeeper-program.test.js +118 -3
  194. package/server/lib/spaces/hardening.js +20 -4
  195. package/server/lib/spaces/hardening.test.js +18 -3
  196. package/server/lib/spaces/host-git.js +7 -4
  197. package/server/lib/spaces/host-git.test.js +6 -2
  198. package/server/lib/spaces/host.js +310 -0
  199. package/server/lib/spaces/host.test.js +273 -0
  200. package/server/lib/spaces/journey.js +552 -0
  201. package/server/lib/spaces/journey.test.js +589 -0
  202. package/server/lib/spaces/layout.js +42 -8
  203. package/server/lib/spaces/manager.js +4 -3
  204. package/server/lib/spaces/manager.test.js +6 -0
  205. package/server/lib/spaces/places/code-out.docker.live.test.js +412 -0
  206. package/server/lib/spaces/places/contract-suite.js +36 -0
  207. package/server/lib/spaces/places/dispatcher.docker.live.test.js +296 -0
  208. package/server/lib/spaces/places/docker-live-support.js +27 -4
  209. package/server/lib/spaces/places/docker.js +33 -5
  210. package/server/lib/spaces/places/docker.test.js +42 -3
  211. package/server/lib/spaces/places/escape-suite.js +118 -14
  212. package/server/lib/spaces/places/fake-docker.js +3 -1
  213. package/server/lib/spaces/places/grants.docker.live.test.js +197 -0
  214. package/server/lib/spaces/places/memory-place.js +24 -1
  215. package/server/lib/spaces/places/registry.js +1 -1
  216. package/server/lib/spaces/places/server.docker.live.test.js +69 -33
  217. package/server/lib/spaces/places/sessions.docker.live.test.js +222 -0
  218. package/server/lib/spaces/routes.js +216 -0
  219. package/server/lib/spaces/routes.test.js +213 -0
  220. package/server/lib/spaces/run-command.js +261 -8
  221. package/server/lib/spaces/run-command.test.js +193 -3
  222. package/server/lib/spaces/space-events.js +153 -0
  223. package/server/lib/spaces/space-events.test.js +160 -0
  224. package/server/lib/spaces/space-opencode.js +52 -0
  225. package/server/lib/spaces/space-opencode.test.js +41 -0
  226. package/server/lib/spaces/space-records.js +150 -0
  227. package/server/lib/spaces/space-records.test.js +113 -0
  228. package/server/lib/spaces/space-sessions.js +210 -0
  229. package/server/lib/spaces/space-sessions.test.js +161 -0
  230. package/server/lib/spaces/websocket.js +329 -0
  231. package/server/lib/spaces/websocket.test.js +345 -0
  232. package/server/lib/ui-auth/ui-auth.js +8 -2
  233. package/server/lib/ui-auth/ui-auth.test.js +29 -0
  234. package/server/opencode-proxy.test.js +271 -0
  235. package/server/proxy-headers.js +8 -2
  236. package/server/proxy-headers.test.js +20 -0
  237. package/dist/assets/DiffView-CX3wAX2S.js +0 -4
  238. package/dist/assets/FilesView-OL3CON3r.js +0 -17
  239. package/dist/assets/GuestSurfacePane-B_cqX5I5.js +0 -1
  240. package/dist/assets/MarkdownImageGallery-DFiTFZgj.js +0 -1
  241. package/dist/assets/MarkdownRendererImpl-DZm5sf1W.js +0 -12
  242. package/dist/assets/PluginPane-BibJit86.js +0 -3
  243. package/dist/assets/SettingsView-h-VpVO47.js +0 -29
  244. package/dist/assets/commitAndPush-BIPfOMXY.js +0 -1
  245. package/dist/assets/de-BqCNGROu.js +0 -10
  246. package/dist/assets/es-BTPpTRgN.js +0 -16
  247. package/dist/assets/fr-hxbzMdAg.js +0 -14
  248. package/dist/assets/index-CLcM8HKx.css +0 -1
  249. package/dist/assets/index-CvLgnuNI.js +0 -41
  250. package/dist/assets/ja-_zLwwYiy.js +0 -16
  251. package/dist/assets/ko-CFn3Dzhc.js +0 -16
  252. package/dist/assets/main-BLJaug9I.js +0 -896
  253. package/dist/assets/markdownCore-DS9FdwrI.js +0 -5
  254. package/dist/assets/patchFileDiff-D9iBcTEi.js +0 -13
  255. package/dist/assets/pl-DFHE7xac.js +0 -16
  256. package/dist/assets/pt-BR-CUjZF179.js +0 -16
  257. package/dist/assets/renderMobileApp-CNQhdXsi.js +0 -25
  258. package/dist/assets/runtimeEndpointReset-DeaERc0M.js +0 -27
  259. package/dist/assets/scheduledTasksApi-BD6Vx9pu.js +0 -1
  260. package/dist/assets/tr-BB6sA_zH.js +0 -7
  261. package/dist/assets/uk-CB4o8g-o.js +0 -16
  262. package/dist/assets/vendor-opencode-client-_BAHQo4R.js +0 -12
  263. package/dist/assets/vendor-zod-Gea9uBlY.js +0 -39
  264. package/dist/assets/zh-CN-BRyDuWhc.js +0 -16
  265. package/dist/assets/zh-TW-BDGbWA1Q.js +0 -16
@@ -3,6 +3,8 @@ import {
3
3
  OPENCHAMBER_AGENT_TOOL_ACTIONS,
4
4
  OPENCHAMBER_MEMORY_ACTION_DEFINITIONS,
5
5
  OPENCHAMBER_MEMORY_ACTIONS,
6
+ OPENCHAMBER_NOTIFY_ACTION_DEFINITIONS,
7
+ OPENCHAMBER_NOTIFY_ACTIONS,
6
8
  resolveAgentToolAction,
7
9
  OPENCHAMBER_WEB_ACTION_DEFINITIONS,
8
10
  OPENCHAMBER_WEB_ACTIONS,
@@ -12,12 +14,18 @@ const TOOL_SCHEMA_VERSION = 1;
12
14
  const PLUGIN_ID = 'openchamber-agent-tool';
13
15
  // Everything either managed tool may ask for; the agent allowlist stays
14
16
  // narrower than the full control surface.
15
- const ACTIONS = new Set([...OPENCHAMBER_AGENT_TOOL_ACTIONS, ...OPENCHAMBER_WEB_ACTIONS, ...OPENCHAMBER_MEMORY_ACTIONS]);
17
+ const ACTIONS = new Set([
18
+ ...OPENCHAMBER_AGENT_TOOL_ACTIONS,
19
+ ...OPENCHAMBER_WEB_ACTIONS,
20
+ ...OPENCHAMBER_MEMORY_ACTIONS,
21
+ ...OPENCHAMBER_NOTIFY_ACTIONS,
22
+ ]);
16
23
  const AGENT_TOOL_ACTION_TITLES = Object.fromEntries(
17
24
  [
18
25
  ...OPENCHAMBER_AGENT_TOOL_ACTION_DEFINITIONS,
19
26
  ...OPENCHAMBER_WEB_ACTION_DEFINITIONS,
20
27
  ...OPENCHAMBER_MEMORY_ACTION_DEFINITIONS,
28
+ ...OPENCHAMBER_NOTIFY_ACTION_DEFINITIONS,
21
29
  ].map(({ action, title }) => [action, title]),
22
30
  );
23
31
 
@@ -28,7 +36,7 @@ const AGENT_TOOL_ACTION_TITLES = Object.fromEntries(
28
36
  * in the other tool's schema, which is both misleading and paid for in context
29
37
  * on every call.
30
38
  */
31
- const WEB_PARAMETER_NAMES = ['url', 'selector', 'text', 'value', 'submit', 'direction', 'viewport', 'label'];
39
+ const WEB_PARAMETER_NAMES = ['url', 'selector', 'text', 'value', 'submit', 'direction', 'viewport', 'label', 'tabId'];
32
40
  // `title` is shared with the control tool, so it is not listed here — only the
33
41
  // names memory alone introduces are kept out of the other schemas.
34
42
  const MEMORY_ONLY_PARAMETER_NAMES = ['body', 'scope', 'memoryId', 'type'];
@@ -87,6 +95,7 @@ const ALL_PARAMETER_PROPERTIES = {
87
95
  direction: { type: 'string', enum: ['up', 'down', 'top', 'bottom'], description: 'Scroll direction for browser.scroll' },
88
96
  viewport: { type: 'string', enum: ['mobile', 'tablet', 'desktop', 'fill'], description: 'Page layout size; snapshots report which one is in effect' },
89
97
  label: { type: 'string', description: 'Short name for a browser.capture image, such as before-fix' },
98
+ tabId: { type: 'string', description: 'Browser tab to act on, an id from the tabs a browser.snapshot lists. Omit to use the tab the user is looking at' },
90
99
  body: { type: 'string', description: 'Full text of the memory; state it so it still makes sense in a session that has none of this conversation' },
91
100
  scope: { type: 'string', enum: ['global', 'project', 'both'], description: 'global is about the user and applies everywhere; project is about this codebase. both is only valid for memory.list' },
92
101
  memoryId: { type: 'string', description: 'Memory ID from a memory.list or memory.read result' },
@@ -108,12 +117,22 @@ const MEMORY_PARAMETER_PROPERTIES = {
108
117
  ...MEMORY_PARAMETER_OVERRIDES,
109
118
  };
110
119
 
120
+ // Its own names, not the shared map: `title` and `body` mean something else in
121
+ // the control and memory tools.
122
+ const NOTIFY_PARAMETER_PROPERTIES = {
123
+ title: { type: 'string', description: 'Short headline the user reads first, up to 120 characters' },
124
+ body: { type: 'string', description: 'One or two sentences of detail, up to 500 characters' },
125
+ showWhenFocused: { type: 'boolean', description: 'Show it even while the user is looking at OpenChamber. Only for something that cannot wait' },
126
+ };
127
+
111
128
  const CONTROL_TOOL_DESCRIPTION = "Control OpenChamber projects, sessions, and scheduled tasks on the user's behalf. Sessions and scheduled tasks you create are for the user to follow and interact with. Do not decide on your own to hand parts of your current task to another session; when the user asks you to create a session, send a prompt to one, or schedule a task, do it, including when the work relates to your current task. Use one action per call. Scope with projectId or directory; omit both to use the current session directory. Session dispatches return immediately by default and you receive no notification when a dispatched session finishes, so never promise to report back on it; the user follows it in OpenChamber; a dispatched session needs no follow-up from you. If the user later asks how it went, use session.messages (add wait to block until it is idle, lastAssistant for just the final answer) — session.send always sends a NEW prompt and never just waits. Set wait only when the user asks or the next step requires the completed result. Session and worktree deletion are unavailable.";
112
129
 
113
130
  const WEB_TOOL_DESCRIPTION = "Look at and interact with a web page in OpenChamber's browser panel, so you can check your own work rather than describing what you expect. Use one action per call. Open a page, snapshot it to read its text and its interactive elements, then click, type or scroll using the selectors the snapshot returned; snapshots also report any errors the page logged. Pass a selector to browser.snapshot to read one part of a long page. browser.inspect returns computed styles when the question is how something renders. Set viewport to check a layout at mobile, tablet or desktop size. The page runs with the user's real logins, so treat what you see as their live session.";
114
131
 
115
132
  const MEMORY_TOOL_DESCRIPTION = "Keep what you learn across sessions, so the user does not have to explain the same thing twice. Use one action per call. The session already lists the titles of what is stored. A title is an abbreviation, not the memory: read the entry with memory.read once before acting on it (it then stays in your context; do not re-read it on later turns), because titles leave out the conditions and exceptions that decide how the memory applies, and the ones that look self-explanatory hide them most often. Save something only when it will still be true in a later session — a stable preference, a project convention, a decision and its reason, or a hard-won pointer. Do not save one-off task state, anything you can read from the code, secrets or credentials, or anything the user asked you not to keep; when the user explicitly asks you to remember something, save it, unless it is a secret or credential. Choose the scope deliberately: global is about the user and reaches every project, so put a project's conventions in project scope. Save in the moment, without asking first, when the user corrects how you work or states a preference, confirms that a non-obvious approach worked, or when you learn a project fact that took real effort to find. One fact per entry. The user can review and remove what you save, so save when it fits and mention it briefly.";
116
133
 
134
+ const NOTIFY_TOOL_DESCRIPTION = "Send the user a notification through OpenChamber, so they learn about something without watching the session. Use it when you finish work that took long enough for the user to step away, when you are blocked on something only the user can resolve, or when the user asked to be told about something. Do not use it for routine progress, for every finished step, or to repeat what your reply already says to a user who is present. Keep the title short and put detail in the body.";
135
+
117
136
  const asNonEmptyString = (value) => {
118
137
  if (typeof value !== 'string') return null;
119
138
  const trimmed = value.trim();
@@ -167,6 +186,40 @@ const resolveConcreteBoundAddress = (value) => {
167
186
  * answer with an empty completion instead of an error, and the `oneOf` branches
168
187
  * are what carry the per-action descriptions the model reads.
169
188
  */
189
+ const createEnvironmentExecutionSource = (name) => `const endpoint = process.env.OPENCHAMBER_AGENT_TOOL_URL
190
+ const token = process.env.OPENCHAMBER_AGENT_TOOL_TOKEN
191
+ const callbackHeaders = {}
192
+ if (!endpoint || !token) {
193
+ return failure({ schemaVersion: ${TOOL_SCHEMA_VERSION}, ok: false, action: args.action, error: { message: "OpenChamber managed tool connection is unavailable" } })
194
+ }
195
+
196
+ try {
197
+ const response = await fetch(endpoint, {
198
+ method: "POST",
199
+ headers: {
200
+ authorization: "Bearer " + token,
201
+ "content-type": "application/json",
202
+ ...callbackHeaders,
203
+ },
204
+ // OpenCode 2 no longer hands a tool its session directory, so the
205
+ // session id goes over instead and OpenChamber resolves the
206
+ // directory on its own side. The signal fires when the session is
207
+ // aborted, so the OpenChamber side stops the action too.
208
+ body: JSON.stringify({ input: args, sessionID: context.sessionID, tool: ${JSON.stringify(name)} }),
209
+ signal: context.signal,
210
+ })
211
+ const content = await response.text()
212
+ let result = null
213
+ try { result = JSON.parse(content) } catch {}
214
+ const valid = result?.schemaVersion === ${TOOL_SCHEMA_VERSION} && typeof result?.ok === "boolean" && typeof result?.action === "string"
215
+ await progress({ ok: valid && result.ok === true })
216
+ if (valid) return { content, metadata: { openchamber: { schemaVersion: ${TOOL_SCHEMA_VERSION}, action: args.action, description: title, ok: result.ok === true } } }
217
+ return failure({ schemaVersion: ${TOOL_SCHEMA_VERSION}, ok: false, action: args.action, error: { message: "OpenChamber returned an invalid response", kind: "runtime", status: response.status } })
218
+ } catch (error) {
219
+ return failure({ schemaVersion: ${TOOL_SCHEMA_VERSION}, ok: false, action: args.action, error: { message: error instanceof Error ? error.message : String(error), kind: "runtime" } })
220
+ }`;
221
+
222
+
170
223
  const createToolEntry = ({ name, description, definitions, parameters }) => String.raw` tools.add({
171
224
  name: ${JSON.stringify(name)},
172
225
  description: ${JSON.stringify(description)},
@@ -199,8 +252,6 @@ const createToolEntry = ({ name, description, definitions, parameters }) => Stri
199
252
  },
200
253
  })
201
254
  await progress()
202
- const endpoint = process.env.OPENCHAMBER_AGENT_TOOL_URL
203
- const token = process.env.OPENCHAMBER_AGENT_TOOL_TOKEN
204
255
  // No output schema is declared, so a result must never carry an output
205
256
  // field: OpenCode rejects that with "Tool result declared output
206
257
  // without an output schema". The envelope travels as text content.
@@ -208,39 +259,12 @@ const createToolEntry = ({ name, description, definitions, parameters }) => Stri
208
259
  content: JSON.stringify(payload),
209
260
  metadata: { openchamber: { schemaVersion: ${TOOL_SCHEMA_VERSION}, action: args.action, description: title, ok: false } },
210
261
  })
211
- if (!endpoint || !token) {
212
- return failure({ schemaVersion: ${TOOL_SCHEMA_VERSION}, ok: false, action: args.action, error: { message: "OpenChamber managed tool connection is unavailable" } })
213
- }
214
-
215
- try {
216
- const response = await fetch(endpoint, {
217
- method: "POST",
218
- headers: {
219
- authorization: "Bearer " + token,
220
- "content-type": "application/json",
221
- },
222
- // OpenCode 2 no longer hands a tool its session directory, so the
223
- // session id goes over instead and OpenChamber resolves the
224
- // directory on its own side. The signal fires when the session is
225
- // aborted, so the OpenChamber side stops the action too.
226
- body: JSON.stringify({ input: args, sessionID: context.sessionID, tool: ${JSON.stringify(name)} }),
227
- signal: context.signal,
228
- })
229
- const content = await response.text()
230
- let result = null
231
- try { result = JSON.parse(content) } catch {}
232
- const valid = result?.schemaVersion === ${TOOL_SCHEMA_VERSION} && typeof result?.ok === "boolean" && typeof result?.action === "string"
233
- await progress({ ok: valid && result.ok === true })
234
- if (valid) return { content, metadata: { openchamber: { schemaVersion: ${TOOL_SCHEMA_VERSION}, action: args.action, description: title, ok: result.ok === true } } }
235
- return failure({ schemaVersion: ${TOOL_SCHEMA_VERSION}, ok: false, action: args.action, error: { message: "OpenChamber returned an invalid response", kind: "runtime", status: response.status } })
236
- } catch (error) {
237
- return failure({ schemaVersion: ${TOOL_SCHEMA_VERSION}, ok: false, action: args.action, error: { message: error instanceof Error ? error.message : String(error), kind: "runtime" } })
238
- }
262
+ ${createEnvironmentExecutionSource(name)}
239
263
  },
240
264
  })
241
265
  `;
242
266
 
243
- const createPluginSource = ({ includeControl, includeWeb, includeMemory }) => {
267
+ export const createPluginSource = ({ includeControl, includeWeb, includeMemory, includeNotify }) => {
244
268
  const entries = [];
245
269
  if (includeControl) {
246
270
  entries.push(createToolEntry({
@@ -266,16 +290,22 @@ const createPluginSource = ({ includeControl, includeWeb, includeMemory }) => {
266
290
  parameters: MEMORY_PARAMETER_PROPERTIES,
267
291
  }));
268
292
  }
293
+ if (includeNotify) {
294
+ entries.push(createToolEntry({
295
+ name: 'openchamber_notify',
296
+ description: NOTIFY_TOOL_DESCRIPTION,
297
+ definitions: OPENCHAMBER_NOTIFY_ACTION_DEFINITIONS,
298
+ parameters: NOTIFY_PARAMETER_PROPERTIES,
299
+ }));
300
+ }
269
301
 
270
- // The callback carries the per-child token over plain HTTP. With a proxy in
271
- // the child's environment, fetch would hand a non-loopback callback, token
272
- // included, to that proxy, and no per-request option turns that off. The
273
- // exemption is added inside the child because only there is the final
274
- // NO_PROXY, merged from the shell and server environments, visible.
275
- return `const exemptCallbackFromProxy = () => {
276
- const endpoint = process.env.OPENCHAMBER_AGENT_TOOL_URL
277
- if (!endpoint || !URL.canParse(endpoint)) return
278
- const host = new URL(endpoint).hostname.replace(/^\\[|\\]$/g, "")
302
+ // The callback carries a bearer token over plain HTTP. With a proxy in the
303
+ // OpenCode environment, fetch could hand a non-loopback callback and token to
304
+ // that proxy. Add the callback host to both proxy exemption variables before
305
+ // a request.
306
+ return `const exemptCallbackFromProxy = (callbackEndpoint = process.env.OPENCHAMBER_AGENT_TOOL_URL) => {
307
+ if (!callbackEndpoint || !URL.canParse(callbackEndpoint)) return
308
+ const host = new URL(callbackEndpoint).hostname.replace(/^\\[|\\]$/g, "")
279
309
  for (const key of ["NO_PROXY", "no_proxy"]) {
280
310
  const entries = (process.env[key] || "").split(",").map((entry) => entry.trim()).filter(Boolean)
281
311
  if (!entries.includes(host)) process.env[key] = [...entries, host].join(",")
@@ -295,7 +325,7 @@ ${entries.join('')} })
295
325
 
296
326
  // A configured plugin has to be a directory carrying a package.json that
297
327
  // resolves an entrypoint; OpenCode skips a plain .js path.
298
- const PLUGIN_PACKAGE_JSON = `${JSON.stringify({
328
+ export const AGENT_TOOL_PLUGIN_PACKAGE_JSON = `${JSON.stringify({
299
329
  name: 'openchamber-agent-tool',
300
330
  version: '0.0.0',
301
331
  private: true,
@@ -329,13 +359,13 @@ export const createAgentToolRuntime = (dependencies) => {
329
359
  * change while it runs, so the source on disk always matches the settings —
330
360
  * the running OpenCode reloads the directory it already has configured.
331
361
  */
332
- const materializePlugin = async ({ includeControl = true, includeWeb = true, includeMemory = true } = {}) => {
333
- if (!includeControl && !includeWeb && !includeMemory) {
362
+ const materializePlugin = async ({ includeControl = true, includeWeb = true, includeMemory = true, includeNotify = false } = {}) => {
363
+ if (!includeControl && !includeWeb && !includeMemory && !includeNotify) {
334
364
  throw new Error('At least one OpenChamber managed tool must be enabled to inject the plugin');
335
365
  }
336
366
  await fsPromises.mkdir(pluginDirectory, { recursive: true });
337
- await fsPromises.writeFile(pluginManifestPath, PLUGIN_PACKAGE_JSON, { mode: 0o600 });
338
- await fsPromises.writeFile(pluginPath, createPluginSource({ includeControl, includeWeb, includeMemory }), { mode: 0o600 });
367
+ await fsPromises.writeFile(pluginManifestPath, AGENT_TOOL_PLUGIN_PACKAGE_JSON, { mode: 0o600 });
368
+ await fsPromises.writeFile(pluginPath, createPluginSource({ includeControl, includeWeb, includeMemory, includeNotify }), { mode: 0o600 });
339
369
  return pluginDirectory;
340
370
  };
341
371
 
@@ -371,15 +401,21 @@ export const createAgentToolRuntime = (dependencies) => {
371
401
  return boundAddress !== null && normalizeAddress(value) === boundAddress;
372
402
  };
373
403
 
374
- const authorize = (req) => {
375
- if (!activeToken || !isSameMachineAddress(req.socket?.remoteAddress)) return false;
404
+ const verifyBearer = (req, token) => {
405
+ if (!token) return false;
376
406
  const header = asNonEmptyString(req.headers?.authorization);
377
407
  if (!header?.startsWith('Bearer ')) return false;
378
408
  const provided = Buffer.from(header.slice(7));
379
- const expected = Buffer.from(activeToken);
409
+ const expected = Buffer.from(token);
380
410
  return provided.length === expected.length && crypto.timingSafeEqual(provided, expected);
381
411
  };
382
412
 
413
+
414
+ const authorizeManagedChild = (req) => (
415
+ isSameMachineAddress(req.socket?.remoteAddress) && verifyBearer(req, activeToken)
416
+ );
417
+
418
+
383
419
  const execute = async (payload = {}, options = {}) => {
384
420
  const requested = asNonEmptyString(payload.input?.action);
385
421
  // Resolved against the calling tool's own actions: models drop the
@@ -455,8 +491,9 @@ export const createAgentToolRuntime = (dependencies) => {
455
491
  };
456
492
 
457
493
  const registerRoutes = (app, express) => {
494
+
458
495
  app.post('/api/openchamber/agent-tool', express.json({ limit: '1mb' }), async (req, res) => {
459
- if (!authorize(req)) return res.status(401).json({ error: 'Unauthorized' });
496
+ if (!authorizeManagedChild(req)) return res.status(401).json({ error: 'Unauthorized' });
460
497
  const controller = new AbortController();
461
498
  const abortOnDisconnect = () => {
462
499
  if (!res.writableEnded) controller.abort();
@@ -484,6 +521,7 @@ export const createAgentToolRuntime = (dependencies) => {
484
521
  pluginDirectory,
485
522
  materializePlugin,
486
523
  createChildEnv,
524
+ authorizeRequest: authorizeManagedChild,
487
525
  registerRoutes,
488
526
  execute,
489
527
  abortSession,
@@ -249,6 +249,21 @@ describe('managed agent tool runtime', () => {
249
249
  expect(Object.keys(tool)).toEqual(['openchamber_memory']);
250
250
  });
251
251
 
252
+ it('exposes notify as its own tool only when switched on', async () => {
253
+ const { runtime, dataDir } = await createRuntime();
254
+ await prepareManagedEnv(runtime, { includeControl: true, includeWeb: false, includeMemory: false, includeNotify: true });
255
+ const tool = await loadTools(dataDir, 'notify');
256
+
257
+ expect(Object.keys(tool)).toEqual(['openchamber', 'openchamber_notify']);
258
+ expect(Object.keys(tool.openchamber_notify.input.properties.parameters.properties).sort())
259
+ .toEqual(['body', 'showWhenFocused', 'title']);
260
+ expect(Object.keys(tool.openchamber.input.properties.parameters.properties)).not.toContain('showWhenFocused');
261
+
262
+ const { runtime: plain, dataDir: plainDir } = await createRuntime();
263
+ await prepareManagedEnv(plain, { includeControl: true, includeWeb: false, includeMemory: false });
264
+ expect(Object.keys(await loadTools(plainDir, 'nonotify'))).toEqual(['openchamber']);
265
+ });
266
+
252
267
  it('refuses to inject a plugin with no tools in it', async () => {
253
268
  const { runtime } = await createRuntime();
254
269
  let failed = false;
@@ -445,6 +460,8 @@ describe('managed agent tool runtime', () => {
445
460
  expect(response.body).toEqual(expect.objectContaining({ ok: true, action: 'projects.list' }));
446
461
  });
447
462
 
463
+
464
+
448
465
  it.each([
449
466
  ['0.0.0.0', 'http://127.0.0.1:3901/api/openchamber/agent-tool'],
450
467
  ['::', 'http://127.0.0.1:3901/api/openchamber/agent-tool'],
@@ -46,8 +46,20 @@ itself; it can only ask and wait.
46
46
  `browser.*` actions of the `openchamber_web` tool onto the router's
47
47
  `request()` (same signature as the broker) and owns their parameter
48
48
  validation.
49
- - The client half is `packages/ui/src/lib/browser/controlClient.ts`, which
50
- registers the mounted browser pane as the one responder.
49
+ - The client half is `packages/ui/src/lib/browser/controlClient.ts`. Every
50
+ mounted browser tab registers its pane under its context-panel tab id. An
51
+ action with `tabId` runs in that tab; without one it runs in the browser tab
52
+ the user last had in front of them (`setShownBrowserTab`, set by
53
+ `ContextPanel`), never in whichever pane registered last, and never switches
54
+ the user to the tab it acts in. `browser.open` without `tabId` never
55
+ navigates an existing tab: the registered opener (`ContextPanel`,
56
+ `useUIStore.openAgentBrowserTab`) makes a new background tab and the answer
57
+ carries its `tabId`. `browser.snapshot` answers carry `tabs`
58
+ (`id`, `title`, `url`, `active`). A client without the named tab waits
59
+ briefly, so the client that has it claims first, then claims and answers
60
+ "no such tab". `tabId` is validated and passed through by
61
+ `../openchamber-control/service.js` for every action, so an extension
62
+ provider receives it untouched (`BrowserTabTarget` in `@openchamber/sdk`).
51
63
 
52
64
  ## Invariants
53
65
 
@@ -29,7 +29,7 @@ This module contains the OpenChamber message-stream WebSocket protocol and runti
29
29
  - `serializeMessageStreamWsEvent(payload, options)` and `sendSerializedMessageStreamWsFrame(socket, frame)` separate encoding from per-socket delivery. Delivery retains ready-state and backpressure checks.
30
30
 
31
31
  ### Runtime helpers
32
- - `createGlobalMessageStreamHub(...)`: creates a shared `/global/event` upstream SSE hub with event/status subscribers and bounded event-id replay.
32
+ - `createGlobalMessageStreamHub(...)`: creates a shared `/global/event` upstream SSE hub with event/status subscribers and bounded event-id replay. The composition root owns the singleton and stops it during application shutdown; watchers and WebSocket bridges that receive it are borrowers and must not stop it.
33
33
  - `createGlobalUiEventBroadcaster({ sseClients, wsClients, writeSseEvent })`: returns a broadcaster that fans out the same synthetic UI event to SSE and WS clients.
34
34
  - `createMessageStreamWsRuntime(...)`: mounts the message-stream WS server, upgrade handler, and SSE-to-WS bridge onto the web HTTP server.
35
35
 
@@ -59,7 +59,7 @@ This module contains the OpenChamber message-stream WebSocket protocol and runti
59
59
  - The hub numbers every event that arrives without an SSE id (`oc-<process>-<sequence>`). OpenCode 1.18 sends no ids at all, and an event without an id used to skip the replay buffer, so reconnecting clients had no cursor and lost whatever fell into the gap. The process prefix makes a cursor from before a server restart miss, which reports `replayReset`, instead of matching an unrelated sequence number.
60
60
  - Directory WS clients still attach one upstream `/event?directory=...` SSE reader per connection because directory streams are scoped.
61
61
  - If an upstream SSE stream stalls after the browser WS is already ready, the reader aborts that upstream fetch and reconnects upstream with `Last-Event-ID`, keeping the browser WS alive when recovery is fast.
62
- - When the shared global upstream reconnects after it was previously ready, the global WS bridge sends a fresh `ready` frame to already-ready browser clients. The browser treats this as a reconnect edge and can run scoped state repair without requiring the browser WS to close.
62
+ - When the shared global upstream reconnects after it was previously ready, the global WS bridge sends a fresh `ready` frame to already-ready browser clients. The browser treats this as a reconnect edge and runs its established scoped authoritative repair without requiring the browser WS to close. Shared-service recovery uses this edge rather than manufacturing a server-side interruption. Rebinding retires the old reader generation before starting its replacement, so delayed events, errors, and disconnect completion from the old reader cannot overwrite the replacement's connection state.
63
63
  - Health checks are reserved for initial upstream connect failures and explicit upstream-unavailable responses, not for ordinary stall recovery on an already-established stream.
64
64
  - Global synthetic events such as `openchamber:session-status`, `openchamber:session-activity`, `openchamber:notification`, and `openchamber:heartbeat` are preserved on the WS path, but heartbeat frames are emitted only while an upstream SSE stream is actively attached.
65
65
  - Global UI broadcasts are fan-out capable across both SSE and WS clients.
@@ -24,6 +24,8 @@ export function createGlobalMessageStreamHub({
24
24
  throw new RangeError('Replay limits must be nonnegative safe integers');
25
25
  }
26
26
  const eventSubscribers = new Set();
27
+ // The event subscribers that also take the events of isolated spaces.
28
+ const spaceSubscribers = new Set();
27
29
  const statusSubscribers = new Set();
28
30
  const replay = [];
29
31
  let replayBytes = 0;
@@ -40,9 +42,9 @@ export function createGlobalMessageStreamHub({
40
42
 
41
43
  let controller = null;
42
44
  let reader = null;
45
+ let readerGeneration = 0;
43
46
  let connected = false;
44
47
  let everConnected = false;
45
- let buildUrlFailed = false;
46
48
 
47
49
  const notifySubscriber = (kind, subscriber, payload) => {
48
50
  try {
@@ -69,6 +71,10 @@ export function createGlobalMessageStreamHub({
69
71
  const eventId = typeof envelope?.eventId === 'string' && envelope.eventId.length > 0
70
72
  ? envelope.eventId
71
73
  : `${replayIdPrefix}${String(++replaySequence).padStart(12, '0')}`;
74
+ // An event of an isolated space carries the space's id; subscribers see it only when they
75
+ // asked for space events, because a consumer that acts on the host's OpenCode by
76
+ // directory must never act on a space's directory.
77
+ const spaceId = typeof envelope?.spaceId === 'string' && envelope.spaceId.length > 0 ? envelope.spaceId : null;
72
78
  let serializedFrame;
73
79
  let translated;
74
80
  return {
@@ -76,6 +82,7 @@ export function createGlobalMessageStreamHub({
76
82
  payload,
77
83
  directory,
78
84
  eventId,
85
+ spaceId,
79
86
  serialize() {
80
87
  serializedFrame ??= serializeMessageStreamWsEvent(payload, { directory, eventId });
81
88
  return serializedFrame;
@@ -112,6 +119,7 @@ export function createGlobalMessageStreamHub({
112
119
  }
113
120
 
114
121
  for (const subscriber of Array.from(eventSubscribers)) {
122
+ if (normalized.spaceId !== null && !spaceSubscribers.has(subscriber)) continue;
115
123
  notifySubscriber('event', subscriber, normalized);
116
124
  }
117
125
  };
@@ -124,8 +132,11 @@ export function createGlobalMessageStreamHub({
124
132
  }
125
133
 
126
134
  controller = new AbortController();
135
+ const generation = ++readerGeneration;
136
+ const currentController = controller;
137
+ let buildUrlFailed = false;
127
138
  reader = createUpstreamSseReader({
128
- signal: controller.signal,
139
+ signal: currentController.signal,
129
140
  stallTimeoutMs: upstreamStallTimeoutMs,
130
141
  reconnectDelayMs: upstreamReconnectDelayMs,
131
142
  fetchImpl,
@@ -140,20 +151,23 @@ export function createGlobalMessageStreamHub({
140
151
  },
141
152
  getHeaders: getOpenCodeAuthHeaders,
142
153
  onConnect() {
154
+ if (generation !== readerGeneration) return;
143
155
  connected = true;
144
156
  const wasReady = everConnected;
145
157
  everConnected = true;
146
158
  notifyStatus({ type: 'connect', wasReady });
147
159
  },
148
160
  onDisconnect({ reason }) {
161
+ if (generation !== readerGeneration) return;
149
162
  connected = false;
150
163
  notifyStatus({ type: 'disconnect', reason });
151
164
  },
152
165
  onEvent(event) {
166
+ if (generation !== readerGeneration) return;
153
167
  coalescer.push(event);
154
168
  },
155
169
  onError(error) {
156
- if (controller?.signal.aborted) {
170
+ if (generation !== readerGeneration || currentController.signal.aborted) {
157
171
  return;
158
172
  }
159
173
 
@@ -168,8 +182,9 @@ export function createGlobalMessageStreamHub({
168
182
  void reader.start();
169
183
  };
170
184
 
171
- const stop = () => {
185
+ const stopReader = ({ resetConnectionHistory }) => {
172
186
  connected = false;
187
+ readerGeneration += 1;
173
188
  // Text that already arrived belongs in the retained replay suffix.
174
189
  coalescer.flush();
175
190
  reader?.stop();
@@ -178,25 +193,48 @@ export function createGlobalMessageStreamHub({
178
193
  }
179
194
  reader = null;
180
195
  controller = null;
181
- everConnected = false;
182
- buildUrlFailed = false;
196
+ if (resetConnectionHistory) {
197
+ everConnected = false;
198
+ }
199
+ };
200
+
201
+ const stop = () => {
202
+ stopReader({ resetConnectionHistory: true });
183
203
  };
184
204
 
185
205
  return {
186
206
  start,
187
207
  stop,
208
+ rebind() {
209
+ stopReader({ resetConnectionHistory: false });
210
+ start();
211
+ },
188
212
  isConnected() {
189
213
  return connected;
190
214
  },
191
215
  hasConnected() {
192
216
  return everConnected;
193
217
  },
194
- subscribeEvent(subscriber) {
218
+ /**
219
+ * `spaces: true` also delivers the events of isolated spaces, which carry `spaceId`.
220
+ * Without it a subscriber sees the host's events only, as every consumer did before spaces.
221
+ */
222
+ subscribeEvent(subscriber, { spaces = false } = {}) {
195
223
  eventSubscribers.add(subscriber);
224
+ if (spaces) spaceSubscribers.add(subscriber);
196
225
  return () => {
197
226
  eventSubscribers.delete(subscriber);
227
+ spaceSubscribers.delete(subscriber);
198
228
  };
199
229
  },
230
+ /**
231
+ * One event of an isolated space, from that space's own connection, entered here as if it
232
+ * had arrived upstream: numbered, coalesced, replayed and fanned out with the host's, so
233
+ * a client keeps one cursor for everything. `directory` is the space's, `spaceId` marks it.
234
+ */
235
+ injectEvent({ payload, directory, spaceId }) {
236
+ coalescer.push({ envelope: { directory, spaceId }, payload });
237
+ },
200
238
  subscribeStatus(subscriber) {
201
239
  statusSubscribers.add(subscriber);
202
240
  return () => {
@@ -25,7 +25,7 @@ it('bounds a contiguous replay suffix by UTF-8 bytes and event count', async ()
25
25
  } finally { hub.stop(); }
26
26
  });
27
27
 
28
- function createSseResponse({ blocks = [] } = {}) {
28
+ function createSseResponse({ blocks = [], signal, holdOpen = false } = {}) {
29
29
  const encoder = new TextEncoder();
30
30
  let index = 0;
31
31
 
@@ -38,7 +38,18 @@ function createSseResponse({ blocks = [] } = {}) {
38
38
  if (index < blocks.length) {
39
39
  return { value: encoder.encode(blocks[index++]), done: false };
40
40
  }
41
- return { value: undefined, done: true };
41
+ if (!holdOpen) {
42
+ return { value: undefined, done: true };
43
+ }
44
+ return new Promise((_resolve, reject) => {
45
+ const onAbort = () => {
46
+ signal.removeEventListener('abort', onAbort);
47
+ const error = new Error('Aborted');
48
+ error.name = 'AbortError';
49
+ reject(error);
50
+ };
51
+ signal.addEventListener('abort', onAbort, { once: true });
52
+ });
42
53
  },
43
54
  };
44
55
  },
@@ -224,6 +235,86 @@ describe('delta coalescing in the global hub', () => {
224
235
  });
225
236
 
226
237
  describe('createGlobalMessageStreamHub', () => {
238
+ it('uses the replacement service endpoint and auth after rebind', async () => {
239
+ let generation = 0;
240
+ const urls = ['http://127.0.0.1:4096/api/event', 'http://127.0.0.1:5096/api/event'];
241
+ const auth = ['Basic first', 'Basic second'];
242
+ const fetchImpl = vi.fn(async (_url, options) => createSseResponse({
243
+ signal: options.signal,
244
+ holdOpen: true,
245
+ }));
246
+ const hub = createGlobalMessageStreamHub({
247
+ buildOpenCodeUrl: () => urls[generation],
248
+ getOpenCodeAuthHeaders: () => ({ Authorization: auth[generation] }),
249
+ fetchImpl,
250
+ upstreamReconnectDelayMs: 60_000,
251
+ });
252
+
253
+ try {
254
+ hub.start();
255
+ await waitForAssertion(() => expect(fetchImpl).toHaveBeenCalledTimes(1));
256
+
257
+ generation = 1;
258
+ hub.rebind();
259
+
260
+ await waitForAssertion(() => expect(fetchImpl).toHaveBeenCalledTimes(2));
261
+ expect(fetchImpl).toHaveBeenLastCalledWith(urls[1], expect.objectContaining({
262
+ headers: expect.objectContaining({ Authorization: auth[1] }),
263
+ }));
264
+ } finally {
265
+ hub.stop();
266
+ }
267
+ });
268
+
269
+ it('ignores a retired reader disconnect that completes after its replacement connects', async () => {
270
+ let finishRetiredRead;
271
+ let fetchCount = 0;
272
+ const statuses = [];
273
+ const fetchImpl = vi.fn(async (_url, options) => {
274
+ fetchCount += 1;
275
+ if (fetchCount === 1) {
276
+ return {
277
+ ok: true,
278
+ body: {
279
+ getReader() {
280
+ return {
281
+ read() {
282
+ return new Promise((resolve) => {
283
+ finishRetiredRead = () => resolve({ value: undefined, done: true });
284
+ });
285
+ },
286
+ };
287
+ },
288
+ },
289
+ };
290
+ }
291
+ return createSseResponse({ signal: options.signal, holdOpen: true });
292
+ });
293
+ const hub = createGlobalMessageStreamHub({
294
+ buildOpenCodeUrl: (pathname) => `http://127.0.0.1:4096${pathname}`,
295
+ getOpenCodeAuthHeaders: () => ({}),
296
+ fetchImpl,
297
+ upstreamReconnectDelayMs: 60_000,
298
+ });
299
+ hub.subscribeStatus((status) => statuses.push(status.type));
300
+
301
+ try {
302
+ hub.start();
303
+ await waitForAssertion(() => expect(statuses).toEqual(['connect']));
304
+
305
+ hub.rebind();
306
+ await waitForAssertion(() => expect(statuses).toEqual(['connect', 'connect']));
307
+ expect(hub.isConnected()).toBe(true);
308
+
309
+ finishRetiredRead();
310
+ await new Promise((resolve) => setTimeout(resolve, 10));
311
+ expect(statuses).toEqual(['connect', 'connect']);
312
+ expect(hub.isConnected()).toBe(true);
313
+ } finally {
314
+ hub.stop();
315
+ }
316
+ });
317
+
227
318
  it('continues fanout when an event subscriber throws', async () => {
228
319
  const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
229
320
  const received = [];
@@ -321,3 +412,38 @@ describe('createGlobalMessageStreamHub', () => {
321
412
  }
322
413
  });
323
414
  });
415
+
416
+ describe('events of isolated spaces in the global hub', () => {
417
+ const makeHub = () => createGlobalMessageStreamHub({
418
+ buildOpenCodeUrl: path => `http://127.0.0.1:4096${path}`,
419
+ getOpenCodeAuthHeaders: () => ({}),
420
+ deltaCoalesceWindowMs: 0,
421
+ fetchImpl: async () => createSseResponse({ blocks: [] }),
422
+ });
423
+
424
+ it('delivers an injected space event to subscribers that asked for spaces only, numbered and replayable like the host\'s', () => {
425
+ const hub = makeHub();
426
+ const plain = [];
427
+ const withSpaces = [];
428
+ hub.subscribeEvent(event => plain.push(event));
429
+ hub.subscribeEvent(event => withSpaces.push(event), { spaces: true });
430
+ hub.injectEvent({ payload: { type: 'session.execution.started', data: { sessionID: 's1' } }, directory: '/spaces/a1b2c3d4e5f6/repo', spaceId: 'a1b2c3d4e5f6' });
431
+ expect(plain).toHaveLength(0);
432
+ expect(withSpaces).toHaveLength(1);
433
+ expect(withSpaces[0]).toMatchObject({ spaceId: 'a1b2c3d4e5f6', directory: '/spaces/a1b2c3d4e5f6/repo' });
434
+ expect(withSpaces[0].eventId).toMatch(/^oc-/);
435
+ expect(withSpaces[0].translated()).toEqual([expect.objectContaining({ type: 'session.status' })]);
436
+ expect(JSON.parse(withSpaces[0].serialize())).toMatchObject({ type: 'event', directory: '/spaces/a1b2c3d4e5f6/repo', payload: { type: 'session.execution.started' } });
437
+ hub.injectEvent({ payload: { type: 'session.execution.succeeded', data: { sessionID: 's1' } }, directory: '/spaces/a1b2c3d4e5f6/repo', spaceId: 'a1b2c3d4e5f6' });
438
+ expect(hub.replayAfter(withSpaces[0].eventId).map(entry => entry.eventId)).toEqual([withSpaces[1].eventId]);
439
+ });
440
+
441
+ it('marks a host event with no space, so every subscriber sees it', () => {
442
+ const hub = makeHub();
443
+ const seen = [];
444
+ hub.subscribeEvent(event => seen.push(event.spaceId));
445
+ hub.subscribeEvent(event => seen.push(event.spaceId), { spaces: true });
446
+ hub.injectEvent({ payload: { type: 'session.execution.started', data: { sessionID: 's1' } }, directory: '/home/me', spaceId: null });
447
+ expect(seen).toEqual([null, null]);
448
+ });
449
+ });
@@ -86,6 +86,8 @@ export function createGlobalMessageStreamWsBridge({
86
86
  }
87
87
  };
88
88
 
89
+ // Browser clients take the events of isolated spaces too: each frame carries its directory,
90
+ // and a space's directory is one more project directory to the UI.
89
91
  const unsubscribeEvent = globalHub.subscribeEvent((event) => {
90
92
  const { payload } = event;
91
93
  for (const socket of Array.from(clients)) {
@@ -111,7 +113,7 @@ export function createGlobalMessageStreamWsBridge({
111
113
  }
112
114
  }
113
115
  });
114
- });
116
+ }, { spaces: true });
115
117
 
116
118
  const unsubscribeStatus = globalHub.subscribeStatus((status) => {
117
119
  if (status.type === 'connect') {