@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
|
@@ -35,8 +35,9 @@ const createFakeService = () => {
|
|
|
35
35
|
};
|
|
36
36
|
const nextFrameAfter = (seq) => frames.find((frame) => frame.seq > seq) ?? null;
|
|
37
37
|
const completed = [];
|
|
38
|
-
|
|
39
|
-
|
|
38
|
+
let inputStatus = 204;
|
|
39
|
+
const openServiceRequest = async ({ path, method, body, query, headers, signal }) => {
|
|
40
|
+
calls.push({ path, method, body: body ? JSON.parse(body) : undefined, query, headers });
|
|
40
41
|
if (failWith) throw failWith;
|
|
41
42
|
if (path === SURFACE_INPUT_PATH && inputGate) {
|
|
42
43
|
const gate = inputGate;
|
|
@@ -75,6 +76,8 @@ const createFakeService = () => {
|
|
|
75
76
|
response = Response.json({ width: 640, height: 400 });
|
|
76
77
|
} else if (path === SURFACE_CLIPBOARD_PATH) {
|
|
77
78
|
response = Response.json({ text: 'copied inside' });
|
|
79
|
+
} else if (path === SURFACE_INPUT_PATH) {
|
|
80
|
+
response = new Response(null, { status: inputStatus });
|
|
78
81
|
} else {
|
|
79
82
|
response = new Response(null, { status: 204 });
|
|
80
83
|
}
|
|
@@ -85,6 +88,7 @@ const createFakeService = () => {
|
|
|
85
88
|
completed,
|
|
86
89
|
pushFrame,
|
|
87
90
|
gateNextInput: (promise) => { inputGate = promise; },
|
|
91
|
+
answerInputWith: (status) => { inputStatus = status; },
|
|
88
92
|
fail: (error) => { failWith = error; for (const wake of waiters.splice(0)) wake(); },
|
|
89
93
|
openServiceRequest,
|
|
90
94
|
callsTo: (path) => calls.filter((call) => call.path === path),
|
|
@@ -101,7 +105,8 @@ const openViewer = (port, guestId = 'sim') => new Promise((resolve, reject) => {
|
|
|
101
105
|
const inbox = [];
|
|
102
106
|
const waiting = [];
|
|
103
107
|
ws.on('message', (data, isBinary) => {
|
|
104
|
-
|
|
108
|
+
// Bun's `ws` hands text frames over as a Uint8Array, not a Buffer.
|
|
109
|
+
const item = isBinary ? { binary: Buffer.from(data) } : JSON.parse(Buffer.from(data).toString('utf8'));
|
|
105
110
|
const waiter = waiting.shift();
|
|
106
111
|
if (waiter) waiter(item);
|
|
107
112
|
else inbox.push(item);
|
|
@@ -214,13 +219,13 @@ describe('guest surface runtime', () => {
|
|
|
214
219
|
await settle();
|
|
215
220
|
expect(runtime.userControls('sim')).toBe(true);
|
|
216
221
|
expect(service.callsTo(SURFACE_INPUT_PATH)[0].body).toEqual({ events: [click] });
|
|
217
|
-
expect(service.callsTo(SURFACE_CONTROL_PATH).map((call) => call.body)).toEqual([{ controller: 'user' }]);
|
|
222
|
+
expect(service.callsTo(SURFACE_CONTROL_PATH).map((call) => call.body)).toEqual([{ controller: 'user', viewer: 'viewer-1' }]);
|
|
218
223
|
|
|
219
224
|
viewer.send({ type: 'release' });
|
|
220
225
|
expect(await viewer.next()).toEqual({ type: 'control', controller: 'none', mine: false });
|
|
221
226
|
await settle();
|
|
222
227
|
expect(runtime.userControls('sim')).toBe(false);
|
|
223
|
-
expect(service.callsTo(SURFACE_CONTROL_PATH).map((call) => call.body)).toEqual([{ controller: 'user' }, { controller: 'none' }]);
|
|
228
|
+
expect(service.callsTo(SURFACE_CONTROL_PATH).map((call) => call.body)).toEqual([{ controller: 'user', viewer: 'viewer-1' }, { controller: 'none' }]);
|
|
224
229
|
});
|
|
225
230
|
|
|
226
231
|
test('a second viewer cannot take control from the first and is told who has it', async () => {
|
|
@@ -367,7 +372,7 @@ describe('guest surface runtime', () => {
|
|
|
367
372
|
viewer.ws.close();
|
|
368
373
|
await viewer.closed;
|
|
369
374
|
await settle();
|
|
370
|
-
expect(service.callsTo(SURFACE_CONTROL_PATH).map((call) => call.body)).toEqual([{ controller: 'user' }, { controller: 'none' }]);
|
|
375
|
+
expect(service.callsTo(SURFACE_CONTROL_PATH).map((call) => call.body)).toEqual([{ controller: 'user', viewer: 'viewer-1' }, { controller: 'none' }]);
|
|
371
376
|
});
|
|
372
377
|
|
|
373
378
|
test('a queued input from before a hand-back does not take control again', async () => {
|
|
@@ -483,4 +488,49 @@ describe('guest surface runtime', () => {
|
|
|
483
488
|
await again.closed;
|
|
484
489
|
expect(runtime.sessionOf('sim')).toBeNull();
|
|
485
490
|
});
|
|
491
|
+
|
|
492
|
+
test('stamps input with the viewer and the service frame it last drew', async () => {
|
|
493
|
+
const { port, service, runtime } = await createHarness();
|
|
494
|
+
const viewer = await openViewer(port);
|
|
495
|
+
await viewer.next(); // hello
|
|
496
|
+
await viewer.next(); // control
|
|
497
|
+
const click = { type: 'pointer', action: 'down', x: 1, y: 1, button: 0, buttons: 1, modifiers: { alt: false, ctrl: false, meta: false, shift: false } };
|
|
498
|
+
|
|
499
|
+
// Nothing drawn yet: frame 0.
|
|
500
|
+
viewer.send({ type: 'input', events: [click] });
|
|
501
|
+
await settle();
|
|
502
|
+
expect(service.callsTo(SURFACE_INPUT_PATH)[0].headers).toEqual({ 'x-surface-viewer': 'viewer-1', 'x-surface-frame-seq': '0' });
|
|
503
|
+
expect(service.callsTo(SURFACE_CONTROL_PATH).at(-1).body).toEqual({ controller: 'user', viewer: 'viewer-1' });
|
|
504
|
+
await viewer.next(); // control: mine
|
|
505
|
+
|
|
506
|
+
service.pushFrame({ seq: 7, bytes: Buffer.from('frame-7') });
|
|
507
|
+
await viewer.next();
|
|
508
|
+
await viewer.next();
|
|
509
|
+
// Sent but not yet drawn: input still counts as made on the old picture.
|
|
510
|
+
viewer.send({ type: 'input', events: [click] });
|
|
511
|
+
viewer.send({ type: 'ack', seq: 7 });
|
|
512
|
+
viewer.send({ type: 'input', events: [click] });
|
|
513
|
+
await settle();
|
|
514
|
+
const stamped = service.callsTo(SURFACE_INPUT_PATH).map((call) => call.headers['x-surface-frame-seq']);
|
|
515
|
+
expect(stamped).toEqual(['0', '0', '7']);
|
|
516
|
+
|
|
517
|
+
expect(runtime.viewerHeaders('sim', 'viewer-1')).toEqual({ 'x-surface-viewer': 'viewer-1', 'x-surface-viewer-controls': '1', 'x-surface-frame-seq': '7' });
|
|
518
|
+
expect(runtime.viewerHeaders('sim', 'viewer-2')).toBeNull();
|
|
519
|
+
expect(runtime.viewerHeaders('other', 'viewer-1')).toBeNull();
|
|
520
|
+
|
|
521
|
+
viewer.send({ type: 'release' });
|
|
522
|
+
await settle();
|
|
523
|
+
expect(runtime.viewerHeaders('sim', 'viewer-1')['x-surface-viewer-controls']).toBe('0');
|
|
524
|
+
});
|
|
525
|
+
|
|
526
|
+
test('tells the viewer when the service refuses input made on an old picture', async () => {
|
|
527
|
+
const { port, service } = await createHarness();
|
|
528
|
+
const viewer = await openViewer(port);
|
|
529
|
+
await viewer.next();
|
|
530
|
+
await viewer.next();
|
|
531
|
+
service.answerInputWith(409);
|
|
532
|
+
viewer.send({ type: 'input', events: [{ type: 'text', text: 'hi' }] });
|
|
533
|
+
await viewer.next(); // control: mine
|
|
534
|
+
expect(await viewer.next()).toMatchObject({ type: 'error', code: 'INPUT_STALE' });
|
|
535
|
+
});
|
|
486
536
|
});
|
|
@@ -9,6 +9,7 @@ This module provides notification message preparation utilities for the web serv
|
|
|
9
9
|
- `packages/web/server/lib/notifications/push-runtime.js`: push subscription persistence, VAPID initialization, and UI visibility runtime.
|
|
10
10
|
- `packages/web/server/lib/notifications/apns-runtime.js`: native iOS APNs device-token persistence + delivery. Two modes: **relay** (default — sign + POST tokens + generic text to the central Cloudflare relay `https://api.openchamber.dev/v1/push/send`, which holds the single project APNs key) and **direct** (fallback — sign ES256 JWT with Node crypto + HTTP/2, when `OPENCHAMBER_PUSH_RELAY_DISABLED=true`). Each server has an auto-generated ECDSA P-256 keypair (`getOrCreateRelayKeypair`, persisted in settings); it binds tokens on the relay (`/v1/push/register-token`) and signs every relay request, so the relay only delivers to tokens bound to that server. APNs is the native app's sole notification channel (no local notifications) and is NOT gated on UI visibility — iOS suppresses the foreground banner instead. Mobile push carries only generic text (scenario title + session name) — see `APNS.md`.
|
|
11
11
|
- `packages/web/server/lib/notifications/emitter-runtime.js`: desktop/stdout + UI SSE notification emission runtime.
|
|
12
|
+
- `packages/web/server/lib/notifications/emit-route.js`: `POST /api/notifications/emit` for OpenCode plugins.
|
|
12
13
|
- `packages/web/server/lib/notifications/runtime.js`: trigger runtime for OpenCode event-driven notification fanout.
|
|
13
14
|
- `packages/web/server/lib/notifications/template-runtime.js`: notification template variables and session text/title enrichment runtime. Zen-model helpers are retained as compatibility stubs only.
|
|
14
15
|
- `packages/web/server/lib/notifications/message.js`: helper implementation module.
|
|
@@ -82,6 +83,15 @@ This module provides notification message preparation utilities for the web serv
|
|
|
82
83
|
- `emitDesktopNotification(payload)`
|
|
83
84
|
- `broadcastUiNotification(payload)`
|
|
84
85
|
|
|
86
|
+
### Plugin notification route (emit-route.js)
|
|
87
|
+
- `createPluginNotificationEmitter(dependencies)` owns validation, the rate window and delivery; one instance is shared by the HTTP route and the `openchamber_notify` agent tool (`notify.send`).
|
|
88
|
+
- `registerNotificationEmitRoutes(app, dependencies)` returns `registerPluginRoute()` and `registerApiRoute()`. Bootstrap calls the first before API auth and the second after it.
|
|
89
|
+
- Callers: a plugin in the managed OpenCode (agent-tool bearer token, the only reason for the pre-auth registration) or any client that passes regular API auth. No other auth path exists.
|
|
90
|
+
- Body `{ title?, body?, tag?, sessionId?, directory?, showWhenFocused? }`, at least one of title/body; title ≤120, body ≤500 chars, 16kb JSON. `kind` is always `plugin` so callers cannot trigger kinds the UI treats specially.
|
|
91
|
+
- One shared rate window: 10 notifications per 10s, then 429 with `Retry-After`.
|
|
92
|
+
- Follows `nativeNotificationsEnabled` (returns `{ delivered: false }`) and `notificationMode` (`requireHidden`); `showWhenFocused: true` sends `requireHidden: false` so the notice shows even while OpenChamber is focused. Live delivery only: no web push or APNs fanout.
|
|
93
|
+
- Advertised as the `api.notifications.emit.v1` server capability.
|
|
94
|
+
|
|
85
95
|
### Template runtime API (template-runtime.js)
|
|
86
96
|
- `createNotificationTemplateRuntime(dependencies)`: creates shared notification/template runtime. Model-backed summarization was retired after the Zen provider became unavailable.
|
|
87
97
|
- Returned API:
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
|
|
3
|
+
const NOTIFICATION_EMIT_PATH = '/api/notifications/emit';
|
|
4
|
+
|
|
5
|
+
const RATE_WINDOW_MS = 10_000;
|
|
6
|
+
const RATE_MAX_PER_WINDOW = 10;
|
|
7
|
+
|
|
8
|
+
const optionalText = (max) => z.string().trim().min(1).max(max).optional();
|
|
9
|
+
|
|
10
|
+
const emitBodySchema = z.object({
|
|
11
|
+
title: optionalText(120),
|
|
12
|
+
body: optionalText(500),
|
|
13
|
+
tag: optionalText(128),
|
|
14
|
+
sessionId: optionalText(128),
|
|
15
|
+
directory: optionalText(4096),
|
|
16
|
+
// Opt-in for notices that matter even while OpenChamber is in front.
|
|
17
|
+
showWhenFocused: z.boolean().optional(),
|
|
18
|
+
}).refine((value) => value.title || value.body, { message: 'title or body required' });
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Raises a notification on behalf of a plugin or the agent, through the same
|
|
22
|
+
* live delivery the built-in triggers use. `kind` is always `plugin`, so a
|
|
23
|
+
* caller cannot impersonate kinds the UI treats specially. One rate window is
|
|
24
|
+
* shared by every caller, the HTTP route and the agent tool alike.
|
|
25
|
+
*
|
|
26
|
+
* `emit` resolves to `{ status, body }`: 200 delivered or skipped by settings,
|
|
27
|
+
* 400 invalid input, 429 rate limited (with `retryAfter` seconds).
|
|
28
|
+
*/
|
|
29
|
+
export const createPluginNotificationEmitter = (dependencies) => {
|
|
30
|
+
const {
|
|
31
|
+
readSettingsFromDiskMigrated,
|
|
32
|
+
emitDesktopNotification,
|
|
33
|
+
broadcastUiNotification,
|
|
34
|
+
now = Date.now,
|
|
35
|
+
} = dependencies;
|
|
36
|
+
|
|
37
|
+
let windowStartedAt = 0;
|
|
38
|
+
let windowCount = 0;
|
|
39
|
+
|
|
40
|
+
const takeRateSlot = () => {
|
|
41
|
+
const current = now();
|
|
42
|
+
if (current - windowStartedAt >= RATE_WINDOW_MS) {
|
|
43
|
+
windowStartedAt = current;
|
|
44
|
+
windowCount = 0;
|
|
45
|
+
}
|
|
46
|
+
if (windowCount >= RATE_MAX_PER_WINDOW) {
|
|
47
|
+
return Math.ceil((windowStartedAt + RATE_WINDOW_MS - current) / 1000);
|
|
48
|
+
}
|
|
49
|
+
windowCount += 1;
|
|
50
|
+
return 0;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const emit = async (input) => {
|
|
54
|
+
const parsed = emitBodySchema.safeParse(input);
|
|
55
|
+
if (!parsed.success) {
|
|
56
|
+
return { status: 400, body: { error: parsed.error.issues[0]?.message ?? 'invalid notification' } };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const retryAfter = takeRateSlot();
|
|
60
|
+
if (retryAfter > 0) {
|
|
61
|
+
return { status: 429, retryAfter, body: { error: 'too many notifications' } };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const settings = await readSettingsFromDiskMigrated();
|
|
65
|
+
if (settings.nativeNotificationsEnabled === false) {
|
|
66
|
+
return { status: 200, body: { delivered: false, reason: 'notifications-disabled' } };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const payload = {
|
|
70
|
+
title: parsed.data.title ?? 'OpenChamber',
|
|
71
|
+
body: parsed.data.body,
|
|
72
|
+
tag: parsed.data.tag,
|
|
73
|
+
kind: 'plugin',
|
|
74
|
+
sessionId: parsed.data.sessionId,
|
|
75
|
+
directory: parsed.data.directory,
|
|
76
|
+
requireHidden: parsed.data.showWhenFocused !== true && settings.notificationMode !== 'always',
|
|
77
|
+
};
|
|
78
|
+
const desktopNotificationDelivered = emitDesktopNotification(payload) === true;
|
|
79
|
+
broadcastUiNotification(payload, { desktopNotificationDelivered });
|
|
80
|
+
return { status: 200, body: { delivered: true } };
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
return { emit };
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* `POST /api/notifications/emit`. Two callers are accepted: a plugin inside
|
|
88
|
+
* the managed OpenCode (the agent-tool bearer token, checked before API auth)
|
|
89
|
+
* and any client that passes the regular API auth.
|
|
90
|
+
*/
|
|
91
|
+
export const registerNotificationEmitRoutes = (app, dependencies) => {
|
|
92
|
+
const { express, isAgentToolRequestAuthorized, emitter } = dependencies;
|
|
93
|
+
|
|
94
|
+
const jsonBody = express.json({ limit: '16kb' });
|
|
95
|
+
|
|
96
|
+
const respond = async (req, res, next) => {
|
|
97
|
+
try {
|
|
98
|
+
const result = await emitter.emit(req.body);
|
|
99
|
+
if (result.retryAfter) res.setHeader('Retry-After', String(result.retryAfter));
|
|
100
|
+
return res.status(result.status).json(result.body);
|
|
101
|
+
} catch (error) {
|
|
102
|
+
return next(error);
|
|
103
|
+
}
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
return {
|
|
107
|
+
// Registered before API auth: a managed plugin carries no UI session.
|
|
108
|
+
registerPluginRoute: () => {
|
|
109
|
+
app.post(NOTIFICATION_EMIT_PATH, jsonBody, (req, res, next) => (
|
|
110
|
+
isAgentToolRequestAuthorized(req) ? respond(req, res, next) : next()
|
|
111
|
+
));
|
|
112
|
+
},
|
|
113
|
+
// Registered after API auth: everything else is an ordinary API caller.
|
|
114
|
+
registerApiRoute: () => {
|
|
115
|
+
app.post(NOTIFICATION_EMIT_PATH, jsonBody, respond);
|
|
116
|
+
},
|
|
117
|
+
};
|
|
118
|
+
};
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import express from 'express';
|
|
3
|
+
import request from 'supertest';
|
|
4
|
+
import { createPluginNotificationEmitter, registerNotificationEmitRoutes } from './emit-route.js';
|
|
5
|
+
|
|
6
|
+
const PLUGIN_TOKEN = 'Bearer plugin-token';
|
|
7
|
+
const UI_TOKEN = 'Bearer ui-token';
|
|
8
|
+
|
|
9
|
+
const createApp = ({ settings = { nativeNotificationsEnabled: true, notificationMode: 'hidden-only' }, now = () => 0 } = {}) => {
|
|
10
|
+
const delivered = [];
|
|
11
|
+
const app = express();
|
|
12
|
+
const emitter = createPluginNotificationEmitter({
|
|
13
|
+
readSettingsFromDiskMigrated: async () => settings,
|
|
14
|
+
emitDesktopNotification: () => false,
|
|
15
|
+
broadcastUiNotification: (payload, options) => delivered.push({ payload, options }),
|
|
16
|
+
now,
|
|
17
|
+
});
|
|
18
|
+
const routes = registerNotificationEmitRoutes(app, {
|
|
19
|
+
express,
|
|
20
|
+
isAgentToolRequestAuthorized: (req) => req.headers.authorization === PLUGIN_TOKEN,
|
|
21
|
+
emitter,
|
|
22
|
+
});
|
|
23
|
+
routes.registerPluginRoute();
|
|
24
|
+
// Stands in for the server's API auth gate between the two registrations.
|
|
25
|
+
app.use('/api', (req, res, next) => (
|
|
26
|
+
req.headers.authorization === UI_TOKEN ? next() : res.status(401).json({ error: 'UI session missing' })
|
|
27
|
+
));
|
|
28
|
+
routes.registerApiRoute();
|
|
29
|
+
return { app, delivered };
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
describe('POST /api/notifications/emit', () => {
|
|
33
|
+
it('delivers a plugin notification without a UI session', async () => {
|
|
34
|
+
const { app, delivered } = createApp();
|
|
35
|
+
const response = await request(app)
|
|
36
|
+
.post('/api/notifications/emit')
|
|
37
|
+
.set('Authorization', PLUGIN_TOKEN)
|
|
38
|
+
.send({ title: ' Build done ', body: 'Ready', sessionId: 'ses_1' });
|
|
39
|
+
|
|
40
|
+
expect(response.status).toBe(200);
|
|
41
|
+
expect(response.body).toEqual({ delivered: true });
|
|
42
|
+
expect(delivered).toEqual([{
|
|
43
|
+
payload: {
|
|
44
|
+
title: 'Build done',
|
|
45
|
+
body: 'Ready',
|
|
46
|
+
tag: undefined,
|
|
47
|
+
kind: 'plugin',
|
|
48
|
+
sessionId: 'ses_1',
|
|
49
|
+
directory: undefined,
|
|
50
|
+
requireHidden: true,
|
|
51
|
+
},
|
|
52
|
+
options: { desktopNotificationDelivered: false },
|
|
53
|
+
}]);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('accepts an ordinary API caller and rejects an unauthenticated one', async () => {
|
|
57
|
+
const { app, delivered } = createApp();
|
|
58
|
+
await request(app).post('/api/notifications/emit').set('Authorization', UI_TOKEN).send({ body: 'Hi' }).expect(200);
|
|
59
|
+
await request(app).post('/api/notifications/emit').send({ body: 'Hi' }).expect(401);
|
|
60
|
+
await request(app).post('/api/notifications/emit').set('Authorization', 'Bearer wrong').send({ body: 'Hi' }).expect(401);
|
|
61
|
+
expect(delivered.map(({ payload }) => payload.title)).toEqual(['OpenChamber']);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('never lets a caller choose the notification kind', async () => {
|
|
65
|
+
const { app, delivered } = createApp();
|
|
66
|
+
await request(app)
|
|
67
|
+
.post('/api/notifications/emit')
|
|
68
|
+
.set('Authorization', PLUGIN_TOKEN)
|
|
69
|
+
.send({ title: 'x', kind: 'opencode-restart-interrupted' })
|
|
70
|
+
.expect(200);
|
|
71
|
+
expect(delivered[0].payload.kind).toBe('plugin');
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('rejects empty and oversized payloads', async () => {
|
|
75
|
+
const { app, delivered } = createApp();
|
|
76
|
+
const post = (body) => request(app).post('/api/notifications/emit').set('Authorization', PLUGIN_TOKEN).send(body);
|
|
77
|
+
await post({}).expect(400);
|
|
78
|
+
await post({ title: ' ' }).expect(400);
|
|
79
|
+
await post({ title: 'x'.repeat(121) }).expect(400);
|
|
80
|
+
await post({ body: 'x'.repeat(501) }).expect(400);
|
|
81
|
+
expect(delivered).toEqual([]);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('limits the rate and recovers after the window', async () => {
|
|
85
|
+
let clock = 0;
|
|
86
|
+
const { app, delivered } = createApp({ now: () => clock });
|
|
87
|
+
const post = () => request(app).post('/api/notifications/emit').set('Authorization', PLUGIN_TOKEN).send({ body: 'tick' });
|
|
88
|
+
for (let index = 0; index < 10; index += 1) await post().expect(200);
|
|
89
|
+
const limited = await post();
|
|
90
|
+
expect(limited.status).toBe(429);
|
|
91
|
+
expect(limited.headers['retry-after']).toBe('10');
|
|
92
|
+
clock = 10_000;
|
|
93
|
+
await post().expect(200);
|
|
94
|
+
expect(delivered).toHaveLength(11);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('follows the notifications setting and the always mode', async () => {
|
|
98
|
+
const off = createApp({ settings: { nativeNotificationsEnabled: false } });
|
|
99
|
+
const skipped = await request(off.app).post('/api/notifications/emit').set('Authorization', PLUGIN_TOKEN).send({ body: 'x' });
|
|
100
|
+
expect(skipped.body).toEqual({ delivered: false, reason: 'notifications-disabled' });
|
|
101
|
+
expect(off.delivered).toEqual([]);
|
|
102
|
+
|
|
103
|
+
const always = createApp({ settings: { nativeNotificationsEnabled: true, notificationMode: 'always' } });
|
|
104
|
+
await request(always.app).post('/api/notifications/emit').set('Authorization', PLUGIN_TOKEN).send({ body: 'x' }).expect(200);
|
|
105
|
+
expect(always.delivered[0].payload.requireHidden).toBe(false);
|
|
106
|
+
|
|
107
|
+
const focused = createApp();
|
|
108
|
+
await request(focused.app).post('/api/notifications/emit').set('Authorization', PLUGIN_TOKEN).send({ body: 'x', showWhenFocused: true }).expect(200);
|
|
109
|
+
expect(focused.delivered[0].payload.requireHidden).toBe(false);
|
|
110
|
+
await request(focused.app).post('/api/notifications/emit').set('Authorization', PLUGIN_TOKEN).send({ body: 'x', showWhenFocused: 'yes' }).expect(400);
|
|
111
|
+
});
|
|
112
|
+
});
|
|
@@ -38,8 +38,8 @@ export const OPENCHAMBER_AGENT_TOOL_ACTIONS = Object.freeze(
|
|
|
38
38
|
);
|
|
39
39
|
|
|
40
40
|
export const OPENCHAMBER_WEB_ACTION_DEFINITIONS = Object.freeze([
|
|
41
|
-
{ action: 'browser.open', title: 'Open a page in the browser panel', description: 'Open url in the in-app browser panel; use it to look at the running app. Set viewport to mobile, tablet or desktop to lay the page out at that size' },
|
|
42
|
-
{ action: 'browser.snapshot', title: 'Read the open page', description: 'Read the open page: url, title, visible text, and interactive elements with the selectors the other browser actions accept. Pass selector to read only that part of a long page. Reports any errors the page logged' },
|
|
41
|
+
{ action: 'browser.open', title: 'Open a page in the browser panel', description: 'Open url in the in-app browser panel; use it to look at the running app. Without tabId it opens a new background tab and answers with its tabId: pass that tabId to the following actions to keep working there. With tabId it loads url in that tab. Set viewport to mobile, tablet or desktop to lay the page out at that size' },
|
|
42
|
+
{ action: 'browser.snapshot', title: 'Read the open page', description: 'Read the open page: url, title, visible text, and interactive elements with the selectors the other browser actions accept. Pass selector to read only that part of a long page. Reports any errors the page logged, and lists the open tabs with the ids tabId accepts' },
|
|
43
43
|
{ action: 'browser.click', title: 'Click on the open page', description: 'Click an element; give selector, or text to match a link or button by its visible label' },
|
|
44
44
|
{ action: 'browser.type', title: 'Type into the open page', description: 'Type value into the field matched by selector; set submit to press Enter afterwards' },
|
|
45
45
|
{ action: 'browser.scroll', title: 'Scroll the open page', description: 'Scroll the page; direction is up, down, top, or bottom, or pass selector to bring one element into view' },
|
|
@@ -76,6 +76,18 @@ export const OPENCHAMBER_MEMORY_ACTIONS = Object.freeze(
|
|
|
76
76
|
OPENCHAMBER_MEMORY_ACTION_DEFINITIONS.map(({ action }) => action),
|
|
77
77
|
);
|
|
78
78
|
|
|
79
|
+
/**
|
|
80
|
+
* Notify is its own tool so it can stay off by default and disappear
|
|
81
|
+
* completely: an agent that can page the user is a choice the user makes.
|
|
82
|
+
*/
|
|
83
|
+
export const OPENCHAMBER_NOTIFY_ACTION_DEFINITIONS = Object.freeze([
|
|
84
|
+
{ action: 'notify.send', title: 'Notify the user', description: 'Send the user a notification; requires title, body is optional. By default it appears only while the user is away from OpenChamber; set showWhenFocused only when it cannot wait' },
|
|
85
|
+
]);
|
|
86
|
+
|
|
87
|
+
export const OPENCHAMBER_NOTIFY_ACTIONS = Object.freeze(
|
|
88
|
+
OPENCHAMBER_NOTIFY_ACTION_DEFINITIONS.map(({ action }) => action),
|
|
89
|
+
);
|
|
90
|
+
|
|
79
91
|
/**
|
|
80
92
|
* Which actions each managed tool may ask for.
|
|
81
93
|
*
|
|
@@ -90,6 +102,7 @@ const ACTIONS_BY_TOOL = Object.freeze({
|
|
|
90
102
|
openchamber: OPENCHAMBER_AGENT_TOOL_ACTIONS,
|
|
91
103
|
openchamber_web: OPENCHAMBER_WEB_ACTIONS,
|
|
92
104
|
openchamber_memory: OPENCHAMBER_MEMORY_ACTIONS,
|
|
105
|
+
openchamber_notify: OPENCHAMBER_NOTIFY_ACTIONS,
|
|
93
106
|
});
|
|
94
107
|
|
|
95
108
|
const bareName = (action) => {
|
|
@@ -136,4 +149,5 @@ export const OPENCHAMBER_ALL_ACTIONS = Object.freeze([
|
|
|
136
149
|
...OPENCHAMBER_CONTROL_ACTIONS,
|
|
137
150
|
...OPENCHAMBER_WEB_ACTIONS,
|
|
138
151
|
...OPENCHAMBER_MEMORY_ACTIONS,
|
|
152
|
+
...OPENCHAMBER_NOTIFY_ACTIONS,
|
|
139
153
|
]);
|
|
@@ -154,6 +154,7 @@ export const createOpenChamberControlService = (dependencies) => {
|
|
|
154
154
|
scheduledTaskService,
|
|
155
155
|
browserControl = null,
|
|
156
156
|
fileOpen = null,
|
|
157
|
+
notifyUser = null,
|
|
157
158
|
agentMemoryActions = null,
|
|
158
159
|
// Archive lives in OpenChamber's own store now — v2 has no route that sets
|
|
159
160
|
// Session.time.archived — so an unwired store simply means nothing is archived.
|
|
@@ -365,6 +366,12 @@ export const createOpenChamberControlService = (dependencies) => {
|
|
|
365
366
|
*/
|
|
366
367
|
const browserAction = async (action, input, signal, contextDirectory, contextSessionId) => {
|
|
367
368
|
const parameters = {};
|
|
369
|
+
// Any action may name a tab; the browser that issued the id resolves it.
|
|
370
|
+
const tabId = asNonEmptyString(input.tabId);
|
|
371
|
+
if (tabId) {
|
|
372
|
+
if (tabId.length > 128) throw new OpenChamberControlError('tabId must be an id from browser.snapshot tabs', 400);
|
|
373
|
+
parameters.tabId = tabId;
|
|
374
|
+
}
|
|
368
375
|
|
|
369
376
|
const readViewport = (required) => {
|
|
370
377
|
const viewport = asNonEmptyString(input.viewport);
|
|
@@ -511,6 +518,22 @@ export const createOpenChamberControlService = (dependencies) => {
|
|
|
511
518
|
}
|
|
512
519
|
return browserAction(action, input, options.signal, contextDirectory, options.contextSessionId);
|
|
513
520
|
}
|
|
521
|
+
if (action === 'notify.send') {
|
|
522
|
+
if (!notifyUser) {
|
|
523
|
+
throw new OpenChamberControlError('Notifications are not available on this server', 503);
|
|
524
|
+
}
|
|
525
|
+
const result = await notifyUser({
|
|
526
|
+
title: input.title,
|
|
527
|
+
body: input.body,
|
|
528
|
+
showWhenFocused: input.showWhenFocused,
|
|
529
|
+
sessionId: asNonEmptyString(options.contextSessionId) || undefined,
|
|
530
|
+
directory: asNonEmptyString(contextDirectory) || undefined,
|
|
531
|
+
});
|
|
532
|
+
if (result.status !== 200) {
|
|
533
|
+
throw new OpenChamberControlError(result.body.error, result.status);
|
|
534
|
+
}
|
|
535
|
+
return result.body;
|
|
536
|
+
}
|
|
514
537
|
if (action === 'file.open') {
|
|
515
538
|
if (!fileOpen) {
|
|
516
539
|
throw new OpenChamberControlError('The file viewer is not available on this server', 503);
|
|
@@ -294,6 +294,31 @@ describe('file.open', () => {
|
|
|
294
294
|
});
|
|
295
295
|
});
|
|
296
296
|
|
|
297
|
+
describe('notify.send', () => {
|
|
298
|
+
it('sends the notice for the calling session and returns what was delivered', async () => {
|
|
299
|
+
const notifyUser = vi.fn(async () => ({ status: 200, body: { delivered: true } }));
|
|
300
|
+
const { service } = createService({ notifyUser });
|
|
301
|
+
|
|
302
|
+
const result = await service.execute('notify.send', { title: 'Done', body: 'All green', showWhenFocused: true }, '/repo', { contextSessionId: 'ses_1' });
|
|
303
|
+
|
|
304
|
+
expect(notifyUser).toHaveBeenCalledWith({ title: 'Done', body: 'All green', showWhenFocused: true, sessionId: 'ses_1', directory: '/repo' });
|
|
305
|
+
expect(result).toEqual({ delivered: true });
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
it('turns a refused notice into an error the agent can read', async () => {
|
|
309
|
+
const notifyUser = vi.fn(async () => ({ status: 429, retryAfter: 4, body: { error: 'too many notifications' } }));
|
|
310
|
+
const { service } = createService({ notifyUser });
|
|
311
|
+
|
|
312
|
+
await expect(service.execute('notify.send', { title: 'Done' }, '/repo'))
|
|
313
|
+
.rejects.toMatchObject({ statusCode: 429, message: 'too many notifications' });
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
it('answers 503 when this server has no notifier wired', async () => {
|
|
317
|
+
const { service } = createService({});
|
|
318
|
+
await expect(service.execute('notify.send', { title: 'Done' }, '/repo')).rejects.toMatchObject({ statusCode: 503 });
|
|
319
|
+
});
|
|
320
|
+
});
|
|
321
|
+
|
|
297
322
|
describe('browser capture', () => {
|
|
298
323
|
const pixel = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==';
|
|
299
324
|
|
|
@@ -338,6 +363,12 @@ describe('browser capture', () => {
|
|
|
338
363
|
await expect(service.execute('browser.capture', {})).rejects.toThrow(/directory is required/);
|
|
339
364
|
});
|
|
340
365
|
|
|
366
|
+
it('passes the tab the agent named to the browser', async () => {
|
|
367
|
+
const { service, directory, request } = await createBrowserService({ base64: pixel, mime: 'image/png' });
|
|
368
|
+
await service.execute('browser.capture', { tabId: ' tab-2 ' }, directory);
|
|
369
|
+
expect(request).toHaveBeenCalledWith('browser.capture', { tabId: 'tab-2' }, expect.anything());
|
|
370
|
+
});
|
|
371
|
+
|
|
341
372
|
it('passes a label through to the browser and leaves other actions untouched', async () => {
|
|
342
373
|
const { service, directory, request } = await createBrowserService({ base64: pixel, mime: 'image/png' });
|
|
343
374
|
await service.execute('browser.capture', { label: 'before' }, directory);
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* What a fork made through the OpenChamber sessions API (CLI, scheduled tasks)
|
|
5
|
+
* keeps from its source session's OpenChamber metadata. Server twin of the UI's
|
|
6
|
+
* `packages/ui/src/lib/sessionForkInheritance.ts`: UI forks call OpenCode
|
|
7
|
+
* directly and repair themselves; forks made here are repaired here.
|
|
8
|
+
*
|
|
9
|
+
* OpenCode 2.x copies the source's `metadata` wholesale into the fork, so the
|
|
10
|
+
* goal already arrives. Two repairs remain:
|
|
11
|
+
*
|
|
12
|
+
* - Source-owned links: the source's btw fork (`btwSessionID`), its review
|
|
13
|
+
* session (`reviewSessionID`), and the btw marker when the source is itself a
|
|
14
|
+
* btw side thread.
|
|
15
|
+
* - A file-backed goal objective: the text lives in a file keyed by the session
|
|
16
|
+
* id, so the fork needs its own copy. When the copy cannot be written the
|
|
17
|
+
* fork's goal carries the text inline instead.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
const NamespaceSchema = z.looseObject({
|
|
21
|
+
btwSessionID: z.unknown().optional(),
|
|
22
|
+
reviewSessionID: z.unknown().optional(),
|
|
23
|
+
kind: z.unknown().optional(),
|
|
24
|
+
goal: z.unknown().optional(),
|
|
25
|
+
});
|
|
26
|
+
const FileBackedGoalSchema = z.looseObject({ id: z.string().min(1), objectiveFile: z.literal(true) });
|
|
27
|
+
|
|
28
|
+
const openChamberNamespace = (metadata) => {
|
|
29
|
+
const parsed = z.looseObject({ openchamber: NamespaceSchema }).safeParse(metadata);
|
|
30
|
+
return parsed.success ? parsed.data.openchamber : null;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
/** Merge patch (RFC 7386) removing source-owned links; null when there are none. */
|
|
34
|
+
export const sourceOwnedLinksPatch = (metadata) => {
|
|
35
|
+
const namespace = openChamberNamespace(metadata);
|
|
36
|
+
if (!namespace) return null;
|
|
37
|
+
const patch = {};
|
|
38
|
+
for (const key of ['btwSessionID', 'reviewSessionID']) {
|
|
39
|
+
if (key in namespace) patch[key] = null;
|
|
40
|
+
}
|
|
41
|
+
if (namespace.kind === 'btw') {
|
|
42
|
+
for (const key of ['kind', 'originalSessionID', 'btwBoundaryMessageID']) {
|
|
43
|
+
if (key in namespace) patch[key] = null;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
// A fork must not pursue the source's goal in parallel: an active goal
|
|
47
|
+
// arrives paused and the user resumes it (same rule as UI forks).
|
|
48
|
+
const goal = namespace.goal;
|
|
49
|
+
if (goal && typeof goal === 'object' && !Array.isArray(goal) && goal.status === 'active') {
|
|
50
|
+
patch.goal = { status: 'paused', statusReason: 'paused in fork' };
|
|
51
|
+
}
|
|
52
|
+
return Object.keys(patch).length > 0 ? patch : null;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const fileBackedGoal = (metadata) => {
|
|
56
|
+
const parsed = FileBackedGoalSchema.safeParse(openChamberNamespace(metadata)?.goal);
|
|
57
|
+
return parsed.success ? parsed.data : null;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Repair a freshly created fork. Best-effort: the fork already exists and is
|
|
62
|
+
* usable, so every failure is logged and swallowed.
|
|
63
|
+
*
|
|
64
|
+
* @param {object} options
|
|
65
|
+
* @param {string} options.sourceSessionID
|
|
66
|
+
* @param {{ id: string, metadata?: object }} options.fork The fork record OpenCode returned.
|
|
67
|
+
* @param {(sessionID: string) => Promise<string | null>} options.readObjective
|
|
68
|
+
* @param {(sessionID: string, content: string) => Promise<unknown>} options.writeObjective Throws on failure.
|
|
69
|
+
* @param {(sessionID: string, patch: object) => Promise<unknown>} options.writeMetadata
|
|
70
|
+
*/
|
|
71
|
+
export const applyForkInheritance = async ({ sourceSessionID, fork, readObjective, writeObjective, writeMetadata }) => {
|
|
72
|
+
const metadata = fork?.metadata;
|
|
73
|
+
const goal = fileBackedGoal(metadata);
|
|
74
|
+
let inlineObjective = null;
|
|
75
|
+
if (goal) {
|
|
76
|
+
try {
|
|
77
|
+
const content = await readObjective(sourceSessionID);
|
|
78
|
+
if (content === null) {
|
|
79
|
+
console.warn('[fork] source goal objective unavailable; fork keeps the inline fallback');
|
|
80
|
+
} else {
|
|
81
|
+
try {
|
|
82
|
+
await writeObjective(fork.id, content);
|
|
83
|
+
} catch (error) {
|
|
84
|
+
console.warn('[fork] failed to copy the goal objective file; inlining it', error?.message ?? error);
|
|
85
|
+
inlineObjective = content;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
} catch (error) {
|
|
89
|
+
console.warn('[fork] failed to read the source goal objective', error?.message ?? error);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const namespacePatch = sourceOwnedLinksPatch(metadata) ?? {};
|
|
94
|
+
if (inlineObjective !== null) {
|
|
95
|
+
namespacePatch.goal = { ...namespacePatch.goal, objective: inlineObjective, objectiveFile: false };
|
|
96
|
+
}
|
|
97
|
+
if (Object.keys(namespacePatch).length === 0) return;
|
|
98
|
+
try {
|
|
99
|
+
await writeMetadata(fork.id, { openchamber: namespacePatch });
|
|
100
|
+
} catch (error) {
|
|
101
|
+
console.warn('[fork] failed to update the fork metadata', error?.message ?? error);
|
|
102
|
+
}
|
|
103
|
+
};
|