@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.
- package/README.md +20 -28
- package/bin/cli.js +3 -1
- package/bin/cli.test.js +13 -4
- package/bin/lib/DOCUMENTATION.md +4 -1
- package/bin/lib/commands-serve.js +9 -0
- package/dist/assets/{CodeMirrorCommentWidgets-CWhdh0wf.js → CodeMirrorCommentWidgets-DCAQTLMM.js} +1 -1
- package/dist/assets/DiffView-uzNpHBlV.js +4 -0
- package/dist/assets/FilesView-Bkq0sjNx.js +17 -0
- package/dist/assets/{GitView-DKIjpnu4.js → GitView-BmibZC7n.js} +4 -4
- package/dist/assets/{GuestAttachDialog-BIa9jCfL.js → GuestAttachDialog-D7TJG1rt.js} +1 -1
- package/dist/assets/GuestSurfacePane-PAcYaFzP.js +1 -0
- package/dist/assets/{JsonTreeView-CtI1Mqwo.js → JsonTreeView-BNT2yUVu.js} +1 -1
- package/dist/assets/{LinearIssuesView-BD2zLu-r.js → LinearIssuesView-D3Azksju.js} +1 -1
- package/dist/assets/MarkdownImageGallery-BGY6tuiK.js +1 -0
- package/dist/assets/MarkdownRendererImpl-DJmhQ37f.js +12 -0
- package/dist/assets/{OnboardingScreen-B8TxloXZ.js → OnboardingScreen-D_41H2qC.js} +1 -1
- package/dist/assets/{PatchDiff--Civs1xY.js → PatchDiff-C0RSqa9W.js} +1 -1
- package/dist/assets/{PierreDiffViewer-BlDmw3-A.js → PierreDiffViewer-BBE7tTY2.js} +3 -3
- package/dist/assets/{PlanView-DyH2SyCP.js → PlanView-Bqc-xuDe.js} +4 -4
- package/dist/assets/PluginPane-DQ0k41Q-.js +3 -0
- package/dist/assets/SettingsView-BAiJU5hJ.js +30 -0
- package/dist/assets/{SettingsWindow-y9WpZqK6.js → SettingsWindow-DuC4ecLw.js} +1 -1
- package/dist/assets/{SubmoduleDiffSummary-DeFmhPFZ.js → SubmoduleDiffSummary-0HgmcMXz.js} +1 -1
- package/dist/assets/{ToolOutputDialog-8DmF7_PZ.js → ToolOutputDialog-DdHkQJEE.js} +6 -6
- package/dist/assets/{ToolPartDiffPreview-Ca15DKE1.js → ToolPartDiffPreview-DWdN0r5K.js} +2 -2
- package/dist/assets/{VirtualizedFileDiff-BfEBad09.js → VirtualizedFileDiff-ClDezf2d.js} +1 -1
- package/dist/assets/{WalkthroughView-Cj3K8Xz6.js → WalkthroughView-BJicp0RC.js} +2 -2
- package/dist/assets/{appThemeRegistry-DAdawJZn.js → appThemeRegistry-CiuyzfSW.js} +1 -1
- package/dist/assets/changesTree-CePHbFcj.js +1 -0
- package/dist/assets/commitAndPush-DTKb6IwT.js +1 -0
- package/dist/assets/{computeVirtualFileMetrics-RF-KjfUH.js → computeVirtualFileMetrics-kM_zJNMt.js} +2 -2
- package/dist/assets/de-BMnfXm-G.js +10 -0
- package/dist/assets/es-DKEvCCL6.js +16 -0
- package/dist/assets/fr-D9fgE2xE.js +14 -0
- package/dist/assets/index-DUke1nme.css +1 -0
- package/dist/assets/{index-CL7ZQ94-.js → index-DWB8bR_g.js} +1 -1
- package/dist/assets/index-Di6zUe5h.js +41 -0
- package/dist/assets/{isDiffPlainText-D2yJ9fO6.js → isDiffPlainText-DOoIsGqs.js} +1 -1
- package/dist/assets/{isFilePlainText-BwMEq1Ey.js → isFilePlainText-bt5VHvcd.js} +1 -1
- package/dist/assets/ja-BEXad-KW.js +16 -0
- package/dist/assets/ko-DGrQ0lEe.js +16 -0
- package/dist/assets/{main-quLSA7jc.js → main-BpLQbZC2.js} +2 -2
- package/dist/assets/main-CI9DtHJv.js +896 -0
- package/dist/assets/{markdown-shiki.worker-CahLvHIq.js → markdown-shiki.worker-Bg8SoAJJ.js} +8 -5
- package/dist/assets/markdownCore-DK2KK5RW.js +7 -0
- package/dist/assets/{miniChat-D-lJJibr.js → miniChat-CIWaR8wG.js} +2 -2
- package/dist/assets/{mobile-VTtphbW6.js → mobile-CnWKEYIP.js} +2 -2
- package/dist/assets/patchFileDiff-Cqn0C0az.js +13 -0
- package/dist/assets/pl-BlhkmR5b.js +16 -0
- package/dist/assets/pt-BR-Bs96Ew9I.js +16 -0
- package/dist/assets/{renderElectronMiniChatApp-DF7ch5eM.js → renderElectronMiniChatApp-D9t0-9nV.js} +2 -2
- package/dist/assets/renderMobileApp-CW7fVs_s.js +25 -0
- package/dist/assets/runtimeEndpointReset-D0PcCb3K.js +27 -0
- package/dist/assets/scheduledTasksApi-Dahv3ws0.js +1 -0
- package/dist/assets/{shikiHighlight-_HeCCHx1.js → shikiHighlight-l-MbaNS4.js} +1 -1
- package/dist/assets/{textMateThemeFromAppTheme-C95XdFGj.js → textMateThemeFromAppTheme-C-kGcOpD.js} +1 -1
- package/dist/assets/tr-CehUK1M9.js +7 -0
- package/dist/assets/uk-Bno5WGNz.js +16 -0
- package/dist/assets/{useAppFontEffects-ZM9ij19-.js → useAppFontEffects-DDjuwVqi.js} +160 -156
- package/dist/assets/{useInlineCommentController-CkfxMJPy.js → useInlineCommentController-Clg8EO3A.js} +2 -2
- package/dist/assets/{useRootScrollLock-BtiAC1fj.js → useRootScrollLock-nDcRrgJ1.js} +2 -2
- package/dist/assets/{vendor-codemirror-language-data-U-lXzXNK.js → vendor-codemirror-language-data-oKF0MAwd.js} +2 -2
- package/dist/assets/vendor-opencode-client-DSZl7jSi.js +12 -0
- package/dist/assets/vendor-zod-BSoePkpP.js +39 -0
- package/dist/assets/{web-components-C583Pmfm.js → web-components-Bc1WPd4i.js} +1 -1
- package/dist/assets/zh-CN-DnAweIWo.js +16 -0
- package/dist/assets/zh-TW-BbBBK25p.js +16 -0
- package/dist/index.html +5 -5
- package/dist/mini-chat.html +5 -5
- package/dist/mobile.html +5 -5
- package/package.json +3 -3
- package/server/index.d.ts +2 -0
- package/server/index.js +196 -52
- package/server/lib/agent-tool/DOCUMENTATION.md +5 -0
- package/server/lib/agent-tool/runtime.js +89 -51
- package/server/lib/agent-tool/runtime.test.js +17 -0
- package/server/lib/browser-control/DOCUMENTATION.md +14 -2
- package/server/lib/event-stream/DOCUMENTATION.md +2 -2
- package/server/lib/event-stream/global-hub.js +45 -7
- package/server/lib/event-stream/global-hub.test.js +128 -2
- package/server/lib/event-stream/global-ws-bridge.js +3 -1
- package/server/lib/event-stream/rebind.test.js +1 -0
- package/server/lib/event-stream/runtime.js +1 -2
- package/server/lib/event-stream/upstream-reader.js +3 -2
- package/server/lib/git/DOCUMENTATION.md +1 -1
- package/server/lib/git/service.js +22 -0
- package/server/lib/git/service.test.js +8 -0
- package/server/lib/guests/DOCUMENTATION.md +4 -3
- package/server/lib/guests/catalog.js +19 -1
- package/server/lib/guests/catalog.test.js +22 -0
- package/server/lib/guests/html-styles.js +5 -3
- package/server/lib/guests/html-styles.test.js +3 -1
- package/server/lib/guests/routes.js +11 -3
- package/server/lib/guests/service.js +5 -0
- package/server/lib/guests/surface.js +55 -9
- package/server/lib/guests/surface.test.js +56 -6
- package/server/lib/notifications/DOCUMENTATION.md +10 -0
- package/server/lib/notifications/emit-route.js +118 -0
- package/server/lib/notifications/emit-route.test.js +112 -0
- package/server/lib/openchamber-control/actions.js +16 -2
- package/server/lib/openchamber-control/service.js +23 -0
- package/server/lib/openchamber-control/service.test.js +31 -0
- package/server/lib/openchamber-sessions/fork-inheritance.js +103 -0
- package/server/lib/openchamber-sessions/fork-inheritance.test.js +69 -0
- package/server/lib/openchamber-sessions/routes.js +11 -0
- package/server/lib/openchamber-sessions/routes.test.js +18 -0
- package/server/lib/opencode/DOCUMENTATION.md +23 -23
- package/server/lib/opencode/bootstrap-runtime.js +14 -1
- package/server/lib/opencode/compatibility.js +6 -3
- package/server/lib/opencode/compatibility.test.js +8 -0
- package/server/lib/opencode/config-entity-v2.test.js +27 -0
- package/server/lib/opencode/config-paths.test.js +12 -0
- package/server/lib/opencode/config-v2.d.ts +2 -0
- package/server/lib/opencode/config-v2.js +42 -0
- package/server/lib/opencode/config-v2.test.js +17 -0
- package/server/lib/opencode/core-routes.js +20 -4
- package/server/lib/opencode/core-routes.test.js +41 -0
- package/server/lib/opencode/env-runtime.js +1 -8
- package/server/lib/opencode/feature-routes-runtime.js +2 -1
- package/server/lib/opencode/lifecycle.js +156 -2
- package/server/lib/opencode/lifecycle.test.js +255 -1
- package/server/lib/opencode/managed-config-file.js +18 -6
- package/server/lib/opencode/managed-config-file.test.js +23 -7
- package/server/lib/opencode/mcp.js +7 -2
- package/server/lib/opencode/network-runtime.js +16 -7
- package/server/lib/opencode/network-runtime.test.js +29 -1
- package/server/lib/opencode/npm-registry-config.d.ts +1 -0
- package/server/lib/opencode/npm-registry-config.js +123 -0
- package/server/lib/opencode/npm-registry-config.test.js +167 -0
- package/server/lib/opencode/npm-registry.js +5 -6
- package/server/lib/opencode/npm-registry.test.js +30 -0
- package/server/lib/opencode/openchamber-routes.js +29 -11
- package/server/lib/opencode/openchamber-routes.test.js +18 -0
- package/server/lib/opencode/project-directory-runtime.js +8 -0
- package/server/lib/opencode/project-directory-runtime.test.js +13 -0
- package/server/lib/opencode/provider-source-route.test.js +59 -0
- package/server/lib/opencode/providers.js +3 -0
- package/server/lib/opencode/providers.test.js +62 -0
- package/server/lib/opencode/proxy.js +53 -16
- package/server/lib/opencode/routes-upgrade.test.js +1 -1
- package/server/lib/opencode/routes.js +36 -1
- package/server/lib/opencode/server-startup-runtime.js +25 -0
- package/server/lib/opencode/server-startup-runtime.test.js +67 -1
- package/server/lib/opencode/server-utils-runtime.js +5 -0
- package/server/lib/opencode/settings-helpers.js +10 -0
- package/server/lib/opencode/settings-helpers.test.js +2 -2
- package/server/lib/opencode/settings-registry.json +17 -0
- package/server/lib/opencode/settings-runtime.js +1 -0
- package/server/lib/opencode/shared-service-runtime.js +155 -0
- package/server/lib/opencode/shared-service-runtime.test.js +180 -0
- package/server/lib/opencode/shared.js +49 -4
- package/server/lib/opencode/shared.test.js +78 -1
- package/server/lib/opencode/shutdown-runtime.js +14 -5
- package/server/lib/opencode/shutdown-runtime.test.js +77 -2
- package/server/lib/opencode/skill-routes.js +28 -12
- package/server/lib/opencode/skill-routes.test.js +121 -1
- package/server/lib/opencode/startup-pipeline-runtime.js +5 -4
- package/server/lib/opencode/startup-pipeline-runtime.test.js +18 -7
- package/server/lib/opencode/upgrade-capability.js +0 -14
- package/server/lib/opencode/v1-migration-topup.js +21 -15
- package/server/lib/opencode/v1-migration-topup.test.js +14 -6
- package/server/lib/opencode/v2-install.d.ts +1 -0
- package/server/lib/opencode/v2-install.js +8 -7
- package/server/lib/opencode/v2-install.test.js +13 -4
- package/server/lib/opencode/watcher.js +3 -1
- package/server/lib/opencode/watcher.test.js +54 -0
- package/server/lib/opencode/websearch-config.js +13 -1
- package/server/lib/quota/providers/kimi.js +1 -1
- package/server/lib/realtime-proxy.js +5 -1
- package/server/lib/realtime-proxy.test.js +29 -0
- package/server/lib/relay/tunnel-host.js +4 -1
- package/server/lib/relay/tunnel-host.test.js +11 -0
- package/server/lib/session-assist/DOCUMENTATION.md +17 -5
- package/server/lib/session-assist/runtime.js +19 -2
- package/server/lib/session-assist/runtime.test.js +8 -0
- package/server/lib/session-goal/DOCUMENTATION.md +11 -0
- package/server/lib/small-model/DOCUMENTATION.md +11 -10
- package/server/lib/small-model/index.js +27 -32
- package/server/lib/small-model/index.test.js +28 -16
- package/server/lib/spaces/DOCUMENTATION.md +404 -32
- package/server/lib/spaces/code-in-bait.js +145 -6
- package/server/lib/spaces/code-in.js +10 -150
- package/server/lib/spaces/code-in.test.js +7 -83
- package/server/lib/spaces/code-out.js +1819 -0
- package/server/lib/spaces/code-out.test.js +3945 -0
- package/server/lib/spaces/code-transfer.js +173 -0
- package/server/lib/spaces/command-stream.test.js +181 -0
- package/server/lib/spaces/dispatcher.js +670 -0
- package/server/lib/spaces/dispatcher.test.js +582 -0
- package/server/lib/spaces/gatekeeper-channel.js +61 -7
- package/server/lib/spaces/gatekeeper-channel.test.js +43 -7
- package/server/lib/spaces/gatekeeper-program.cjs +68 -14
- package/server/lib/spaces/gatekeeper-program.test.js +118 -3
- package/server/lib/spaces/hardening.js +20 -4
- package/server/lib/spaces/hardening.test.js +18 -3
- package/server/lib/spaces/host-git.js +7 -4
- package/server/lib/spaces/host-git.test.js +6 -2
- package/server/lib/spaces/host.js +310 -0
- package/server/lib/spaces/host.test.js +273 -0
- package/server/lib/spaces/journey.js +552 -0
- package/server/lib/spaces/journey.test.js +589 -0
- package/server/lib/spaces/layout.js +42 -8
- package/server/lib/spaces/manager.js +4 -3
- package/server/lib/spaces/manager.test.js +6 -0
- package/server/lib/spaces/places/code-out.docker.live.test.js +412 -0
- package/server/lib/spaces/places/contract-suite.js +36 -0
- package/server/lib/spaces/places/dispatcher.docker.live.test.js +296 -0
- package/server/lib/spaces/places/docker-live-support.js +27 -4
- package/server/lib/spaces/places/docker.js +33 -5
- package/server/lib/spaces/places/docker.test.js +42 -3
- package/server/lib/spaces/places/escape-suite.js +118 -14
- package/server/lib/spaces/places/fake-docker.js +3 -1
- package/server/lib/spaces/places/grants.docker.live.test.js +197 -0
- package/server/lib/spaces/places/memory-place.js +24 -1
- package/server/lib/spaces/places/registry.js +1 -1
- package/server/lib/spaces/places/server.docker.live.test.js +69 -33
- package/server/lib/spaces/places/sessions.docker.live.test.js +222 -0
- package/server/lib/spaces/routes.js +216 -0
- package/server/lib/spaces/routes.test.js +213 -0
- package/server/lib/spaces/run-command.js +261 -8
- package/server/lib/spaces/run-command.test.js +193 -3
- package/server/lib/spaces/space-events.js +153 -0
- package/server/lib/spaces/space-events.test.js +160 -0
- package/server/lib/spaces/space-opencode.js +52 -0
- package/server/lib/spaces/space-opencode.test.js +41 -0
- package/server/lib/spaces/space-records.js +150 -0
- package/server/lib/spaces/space-records.test.js +113 -0
- package/server/lib/spaces/space-sessions.js +210 -0
- package/server/lib/spaces/space-sessions.test.js +161 -0
- package/server/lib/spaces/websocket.js +329 -0
- package/server/lib/spaces/websocket.test.js +345 -0
- package/server/lib/ui-auth/ui-auth.js +8 -2
- package/server/lib/ui-auth/ui-auth.test.js +29 -0
- package/server/opencode-proxy.test.js +271 -0
- package/server/proxy-headers.js +8 -2
- package/server/proxy-headers.test.js +20 -0
- package/dist/assets/DiffView-CX3wAX2S.js +0 -4
- package/dist/assets/FilesView-OL3CON3r.js +0 -17
- package/dist/assets/GuestSurfacePane-B_cqX5I5.js +0 -1
- package/dist/assets/MarkdownImageGallery-DFiTFZgj.js +0 -1
- package/dist/assets/MarkdownRendererImpl-DZm5sf1W.js +0 -12
- package/dist/assets/PluginPane-BibJit86.js +0 -3
- package/dist/assets/SettingsView-h-VpVO47.js +0 -29
- package/dist/assets/commitAndPush-BIPfOMXY.js +0 -1
- package/dist/assets/de-BqCNGROu.js +0 -10
- package/dist/assets/es-BTPpTRgN.js +0 -16
- package/dist/assets/fr-hxbzMdAg.js +0 -14
- package/dist/assets/index-CLcM8HKx.css +0 -1
- package/dist/assets/index-CvLgnuNI.js +0 -41
- package/dist/assets/ja-_zLwwYiy.js +0 -16
- package/dist/assets/ko-CFn3Dzhc.js +0 -16
- package/dist/assets/main-BLJaug9I.js +0 -896
- package/dist/assets/markdownCore-DS9FdwrI.js +0 -5
- package/dist/assets/patchFileDiff-D9iBcTEi.js +0 -13
- package/dist/assets/pl-DFHE7xac.js +0 -16
- package/dist/assets/pt-BR-CUjZF179.js +0 -16
- package/dist/assets/renderMobileApp-CNQhdXsi.js +0 -25
- package/dist/assets/runtimeEndpointReset-DeaERc0M.js +0 -27
- package/dist/assets/scheduledTasksApi-BD6Vx9pu.js +0 -1
- package/dist/assets/tr-BB6sA_zH.js +0 -7
- package/dist/assets/uk-CB4o8g-o.js +0 -16
- package/dist/assets/vendor-opencode-client-_BAHQo4R.js +0 -12
- package/dist/assets/vendor-zod-Gea9uBlY.js +0 -39
- package/dist/assets/zh-CN-BRyDuWhc.js +0 -16
- 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([
|
|
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
|
-
|
|
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
|
|
271
|
-
//
|
|
272
|
-
//
|
|
273
|
-
//
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
const
|
|
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
|
|
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,
|
|
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
|
|
375
|
-
if (!
|
|
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(
|
|
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 (!
|
|
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
|
|
50
|
-
|
|
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
|
|
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:
|
|
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 (
|
|
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
|
|
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
|
-
|
|
182
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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') {
|