@vantran-se/openchamber-web 2.0.4 → 2.0.6
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/dist/assets/{CodeMirrorCommentWidgets-DZF8ntaq.js → CodeMirrorCommentWidgets-BlzB2e0C.js} +1 -1
- package/dist/assets/DiffView-D7JQzQVZ.js +4 -0
- package/dist/assets/FilesView-DC2gadXM.js +17 -0
- package/dist/assets/{GitView-DwQwqDvu.js → GitView-Gzl0wbdd.js} +4 -4
- package/dist/assets/{GuestAttachDialog-CoXM0gh_.js → GuestAttachDialog-CQdDIeco.js} +1 -1
- package/dist/assets/GuestSurfacePane-CrOhlIdt.js +1 -0
- package/dist/assets/{JsonTreeView-Cq_27v0y.js → JsonTreeView-C3QZifYe.js} +1 -1
- package/dist/assets/{LinearIssuesView-CsSSRMhn.js → LinearIssuesView-X27TCI3v.js} +1 -1
- package/dist/assets/MarkdownImageGallery-BlTWU9oH.js +1 -0
- package/dist/assets/MarkdownRendererImpl-Cno7QsJO.js +12 -0
- package/dist/assets/{OnboardingScreen-ClatMQAX.js → OnboardingScreen-Wr_UzNtt.js} +1 -1
- package/dist/assets/{PatchDiff-DOwlrIK9.js → PatchDiff-D6pWS7cD.js} +1 -1
- package/dist/assets/{PierreDiffViewer-CrcGqqD_.js → PierreDiffViewer-DDljFbJJ.js} +3 -3
- package/dist/assets/{PlanView-BGLsMgQk.js → PlanView-LcfEbBPi.js} +4 -4
- package/dist/assets/PluginPane-DPWPLc2f.js +3 -0
- package/dist/assets/SettingsView-CFe9O3EM.js +30 -0
- package/dist/assets/{SettingsWindow-B0BAy-f9.js → SettingsWindow-DT1xhvgi.js} +1 -1
- package/dist/assets/{SubmoduleDiffSummary-fcdDzCSW.js → SubmoduleDiffSummary-CBHXaIkB.js} +1 -1
- package/dist/assets/{ToolOutputDialog-B7xaUz2H.js → ToolOutputDialog-B_ZX8ZbZ.js} +6 -6
- package/dist/assets/{ToolPartDiffPreview-CkWCaUmZ.js → ToolPartDiffPreview-D_HQzkXU.js} +2 -2
- package/dist/assets/{VirtualizedFileDiff-DONC4eI2.js → VirtualizedFileDiff-BUK1ujXg.js} +1 -1
- package/dist/assets/{WalkthroughView-CXDImOXW.js → WalkthroughView-H-tYOvhO.js} +2 -2
- package/dist/assets/{appThemeRegistry-Br2TLSlb.js → appThemeRegistry-BoJGAwCP.js} +1 -1
- package/dist/assets/changesTree-CePHbFcj.js +1 -0
- package/dist/assets/commitAndPush-BzZrF8bK.js +1 -0
- package/dist/assets/{computeVirtualFileMetrics-_-20umCs.js → computeVirtualFileMetrics-kYyVjXLu.js} +2 -2
- package/dist/assets/de-ByfQDcPj.js +10 -0
- package/dist/assets/es-DzNbjsQS.js +16 -0
- package/dist/assets/fr-B8AVLrnX.js +14 -0
- package/dist/assets/{index-OKDYjcMF.js → index-0-ON1L7L.js} +1 -1
- package/dist/assets/index-DUke1nme.css +1 -0
- package/dist/assets/index-Di6zUe5h.js +41 -0
- package/dist/assets/{isDiffPlainText-D77M6q1f.js → isDiffPlainText-Bgm_aLpu.js} +1 -1
- package/dist/assets/{isFilePlainText-C54B4Mns.js → isFilePlainText-D5GMgURH.js} +1 -1
- package/dist/assets/ja-1g6S0G19.js +16 -0
- package/dist/assets/ko-CxplvYkJ.js +16 -0
- package/dist/assets/{main-CGEvLMd2.js → main--Y98ZLsD.js} +2 -2
- package/dist/assets/main-GjXmVNpD.js +896 -0
- package/dist/assets/{markdown-shiki.worker-CahLvHIq.js → markdown-shiki.worker-Bg8SoAJJ.js} +8 -5
- package/dist/assets/markdownCore-CVdUcVCi.js +7 -0
- package/dist/assets/{miniChat-BSxkRQLj.js → miniChat-tTx5mpbd.js} +2 -2
- package/dist/assets/{mobile-C5KRTNOB.js → mobile-BmbiDWGb.js} +2 -2
- package/dist/assets/patchFileDiff-CV4iVQ8L.js +13 -0
- package/dist/assets/pl-f3QyqvIB.js +16 -0
- package/dist/assets/pt-BR-BsaoaiCK.js +16 -0
- package/dist/assets/{renderElectronMiniChatApp-CnkAQV6o.js → renderElectronMiniChatApp-BnCdnDhv.js} +2 -2
- package/dist/assets/renderMobileApp-CKn8nbSW.js +25 -0
- package/dist/assets/runtimeEndpointReset-sCi88eed.js +27 -0
- package/dist/assets/scheduledTasksApi-Dahv3ws0.js +1 -0
- package/dist/assets/{shikiHighlight-4O0ZM14D.js → shikiHighlight-O5_xLmWt.js} +1 -1
- package/dist/assets/{textMateThemeFromAppTheme-yNq7b0DZ.js → textMateThemeFromAppTheme-XM4VefqN.js} +1 -1
- package/dist/assets/tr-BNFsa6nz.js +7 -0
- package/dist/assets/uk-CJ9y5ULA.js +16 -0
- package/dist/assets/{useAppFontEffects-Bzyszv2N.js → useAppFontEffects-BN06mgHB.js} +160 -156
- package/dist/assets/{useInlineCommentController-zOf6ORAq.js → useInlineCommentController-Bb9Gjz1Q.js} +2 -2
- package/dist/assets/{useRootScrollLock-DCaZ4BWi.js → useRootScrollLock-Chk1cFUw.js} +2 -2
- package/dist/assets/{vendor-codemirror-language-data-6cEFPNUR.js → vendor-codemirror-language-data-C2qMZiA7.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-BEo4-ISu.js → web-components-BQWjCn_U.js} +1 -1
- package/dist/assets/zh-CN-U0QjVlAA.js +16 -0
- package/dist/assets/zh-TW-J9PTJQJi.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 +4 -4
- package/server/index.js +82 -0
- package/server/lib/agent-tool/DOCUMENTATION.md +132 -47
- package/server/lib/agent-tool/runtime.js +34 -15
- package/server/lib/agent-tool/runtime.test.js +15 -0
- package/server/lib/browser-control/DOCUMENTATION.md +14 -2
- package/server/lib/event-stream/global-hub.js +23 -1
- package/server/lib/event-stream/global-hub.test.js +35 -0
- package/server/lib/event-stream/global-ws-bridge.js +3 -1
- 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/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 +30 -2
- package/server/lib/opencode/lifecycle.test.js +27 -0
- 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/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 +38 -2
- package/server/lib/opencode/routes-upgrade.test.js +1 -1
- package/server/lib/opencode/routes.js +36 -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.js +49 -4
- package/server/lib/opencode/shared.test.js +78 -1
- package/server/lib/opencode/shutdown-runtime.js +3 -0
- package/server/lib/opencode/skill-routes.js +28 -12
- package/server/lib/opencode/skill-routes.test.js +121 -1
- 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/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 +113 -0
- package/dist/assets/DiffView-D2p4Ar0H.js +0 -4
- package/dist/assets/FilesView-BeTtbsip.js +0 -17
- package/dist/assets/GuestSurfacePane-DIJvkkM-.js +0 -1
- package/dist/assets/MarkdownImageGallery-CY0dWTaE.js +0 -1
- package/dist/assets/MarkdownRendererImpl-ILdXzLrt.js +0 -12
- package/dist/assets/PluginPane-B39JEUL8.js +0 -3
- package/dist/assets/SettingsView-13B2_G-A.js +0 -29
- package/dist/assets/commitAndPush-CcI4ol7c.js +0 -1
- package/dist/assets/de-BtYnUgII.js +0 -10
- package/dist/assets/es-crP5qPye.js +0 -16
- package/dist/assets/fr-CLSRuALk.js +0 -14
- package/dist/assets/index-CLcM8HKx.css +0 -1
- package/dist/assets/index-CvLgnuNI.js +0 -41
- package/dist/assets/ja-SZ1-ucLV.js +0 -16
- package/dist/assets/ko-HthbT_Ve.js +0 -16
- package/dist/assets/main-BJklC7Cl.js +0 -896
- package/dist/assets/markdownCore-BzQuETm0.js +0 -5
- package/dist/assets/patchFileDiff-DGw2KryM.js +0 -13
- package/dist/assets/pl-C8eYfjL1.js +0 -16
- package/dist/assets/pt-BR-cYOrZdxS.js +0 -16
- package/dist/assets/renderMobileApp-CMxjwisj.js +0 -25
- package/dist/assets/runtimeEndpointReset-C45jrFq4.js +0 -27
- package/dist/assets/scheduledTasksApi-CkK1F9s2.js +0 -1
- package/dist/assets/tr-B4IQ5t-T.js +0 -7
- package/dist/assets/uk-DCoXG6ve.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-BXBoxsFk.js +0 -16
- package/dist/assets/zh-TW-C6oelpnL.js +0 -16
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { applyForkInheritance, sourceOwnedLinksPatch } from './fork-inheritance.js';
|
|
3
|
+
|
|
4
|
+
const goal = (objectiveFile) => ({ id: 'goal_1', objective: objectiveFile ? '' : 'Ship it', objectiveFile, status: 'paused' });
|
|
5
|
+
|
|
6
|
+
const harness = (metadata, overrides = {}) => {
|
|
7
|
+
const deps = {
|
|
8
|
+
readObjective: vi.fn(async () => 'Objective from file'),
|
|
9
|
+
writeObjective: vi.fn(async () => undefined),
|
|
10
|
+
writeMetadata: vi.fn(async () => undefined),
|
|
11
|
+
...overrides,
|
|
12
|
+
};
|
|
13
|
+
const run = () => applyForkInheritance({ sourceSessionID: 'ses_source', fork: { id: 'ses_fork', metadata }, ...deps });
|
|
14
|
+
return { deps, run };
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
describe('sourceOwnedLinksPatch', () => {
|
|
18
|
+
it('pauses an active goal so the fork does not pursue it in parallel', () => {
|
|
19
|
+
expect(sourceOwnedLinksPatch({ openchamber: { goal: { id: 'g', status: 'active' } } }))
|
|
20
|
+
.toEqual({ goal: { status: 'paused', statusReason: 'paused in fork' } });
|
|
21
|
+
expect(sourceOwnedLinksPatch({ openchamber: { goal: { id: 'g', status: 'complete' } } })).toBeNull();
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('removes the btw and review links but keeps the goal', () => {
|
|
25
|
+
expect(sourceOwnedLinksPatch({ openchamber: { goal: goal(false), btwSessionID: 'b', reviewSessionID: 'r' } }))
|
|
26
|
+
.toEqual({ btwSessionID: null, reviewSessionID: null });
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('removes the btw marker of a btw source, keeps a review marker', () => {
|
|
30
|
+
expect(sourceOwnedLinksPatch({ openchamber: { kind: 'btw', originalSessionID: 'p', btwBoundaryMessageID: 'm' } }))
|
|
31
|
+
.toEqual({ kind: null, originalSessionID: null, btwBoundaryMessageID: null });
|
|
32
|
+
expect(sourceOwnedLinksPatch({ openchamber: { kind: 'review', originalSessionID: 'p' } })).toBeNull();
|
|
33
|
+
expect(sourceOwnedLinksPatch(undefined)).toBeNull();
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
describe('applyForkInheritance', () => {
|
|
38
|
+
it('does nothing for an inline goal without source links', async () => {
|
|
39
|
+
const h = harness({ openchamber: { goal: goal(false) } });
|
|
40
|
+
await h.run();
|
|
41
|
+
expect(h.deps.readObjective).not.toHaveBeenCalled();
|
|
42
|
+
expect(h.deps.writeMetadata).not.toHaveBeenCalled();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('copies a file-backed objective and strips source links', async () => {
|
|
46
|
+
const h = harness({ openchamber: { goal: goal(true), btwSessionID: 'b' } });
|
|
47
|
+
await h.run();
|
|
48
|
+
expect(h.deps.readObjective).toHaveBeenCalledWith('ses_source');
|
|
49
|
+
expect(h.deps.writeObjective).toHaveBeenCalledWith('ses_fork', 'Objective from file');
|
|
50
|
+
expect(h.deps.writeMetadata).toHaveBeenCalledWith('ses_fork', { openchamber: { btwSessionID: null } });
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('inlines the objective when the file copy fails', async () => {
|
|
54
|
+
const h = harness({ openchamber: { goal: goal(true) } }, {
|
|
55
|
+
writeObjective: vi.fn(async () => { throw new Error('disk full'); }),
|
|
56
|
+
});
|
|
57
|
+
await h.run();
|
|
58
|
+
expect(h.deps.writeMetadata).toHaveBeenCalledWith('ses_fork', {
|
|
59
|
+
openchamber: { goal: { objective: 'Objective from file', objectiveFile: false } },
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('swallows a metadata write failure', async () => {
|
|
64
|
+
const h = harness({ openchamber: { reviewSessionID: 'r' } }, {
|
|
65
|
+
writeMetadata: vi.fn(async () => { throw new Error('offline'); }),
|
|
66
|
+
});
|
|
67
|
+
await expect(h.run()).resolves.toBeUndefined();
|
|
68
|
+
});
|
|
69
|
+
});
|
|
@@ -9,7 +9,9 @@ import { AUTO_MODEL_REF, isAutoModel } from '../routing/defaults.js';
|
|
|
9
9
|
import { parseScheduledCommandPrompt } from '../scheduled-tasks/runtime.js';
|
|
10
10
|
import { buildGoalIntroText, createSessionGoal } from '../session-goal/create.js';
|
|
11
11
|
import { OpenChamberControlError, asControlError } from '../openchamber-control/error.js';
|
|
12
|
+
import { readObjective, writeObjective } from '../session-goal/objectives.js';
|
|
12
13
|
import { createArchiveStore } from './archive-store.js';
|
|
14
|
+
import { applyForkInheritance } from './fork-inheritance.js';
|
|
13
15
|
import { createOpenCodeClient as defaultCreateOpenCodeClient } from './opencode-client.js';
|
|
14
16
|
import { createSessionMetadataStore, createOpenCodeSessionMetadata } from './session-metadata-store.js';
|
|
15
17
|
|
|
@@ -889,6 +891,15 @@ export const createOpenChamberSessionService = (dependencies) => {
|
|
|
889
891
|
messageID: asNonEmptyString(payload.messageId) || undefined,
|
|
890
892
|
});
|
|
891
893
|
targetSessionID = targetSession.id;
|
|
894
|
+
// Before the prompt goes out, so a goal armed by this dispatch writes
|
|
895
|
+
// over the copied objective rather than the other way round.
|
|
896
|
+
await applyForkInheritance({
|
|
897
|
+
sourceSessionID,
|
|
898
|
+
fork: targetSession,
|
|
899
|
+
readObjective,
|
|
900
|
+
writeObjective,
|
|
901
|
+
writeMetadata: (sessionID, patch) => writeMetadata(sessionID, patch, directory),
|
|
902
|
+
});
|
|
892
903
|
}
|
|
893
904
|
|
|
894
905
|
const baselineAssistantMessageId = await latestCompletedAssistantMessageID({
|
|
@@ -920,6 +920,24 @@ describe('openchamber session routes', () => {
|
|
|
920
920
|
}));
|
|
921
921
|
});
|
|
922
922
|
|
|
923
|
+
it('strips source-owned links from a fork before dispatching', async () => {
|
|
924
|
+
sessionForkMock.mockImplementationOnce(async () => ({
|
|
925
|
+
id: 'ses_fork',
|
|
926
|
+
title: 'Forked session',
|
|
927
|
+
metadata: { openchamber: { btwSessionID: 'ses_btw', reviewSessionID: 'ses_review', assist: { recap: 'kept' } } },
|
|
928
|
+
}));
|
|
929
|
+
const { app, sessionMetadataStore } = createApp();
|
|
930
|
+
sessionMetadataStore.entries.set('ses_fork', {
|
|
931
|
+
openchamber: { btwSessionID: 'ses_btw', reviewSessionID: 'ses_review', assist: { recap: 'kept' } },
|
|
932
|
+
});
|
|
933
|
+
await request(app)
|
|
934
|
+
.post('/api/openchamber/sessions/ses_source/fork')
|
|
935
|
+
.send({ directory: '/repo/app', prompt: 'Carry on', model: 'openai/gpt-5.5', agent: 'build' })
|
|
936
|
+
.expect(200);
|
|
937
|
+
|
|
938
|
+
expect(sessionMetadataStore.entries.get('ses_fork')).toEqual({ openchamber: { assist: { recap: 'kept' } } });
|
|
939
|
+
});
|
|
940
|
+
|
|
923
941
|
it('rejects send and fork requests without a prompt before calling OpenCode', async () => {
|
|
924
942
|
const { app } = createApp();
|
|
925
943
|
await request(app)
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { registerNotificationEmitRoutes } from '../notifications/emit-route.js';
|
|
2
|
+
|
|
1
3
|
export const createBootstrapRuntime = (dependencies) => {
|
|
2
4
|
const {
|
|
3
5
|
createUiAuth,
|
|
@@ -63,7 +65,9 @@ export const createBootstrapRuntime = (dependencies) => {
|
|
|
63
65
|
getCachedZenModels,
|
|
64
66
|
setAutoAcceptSession,
|
|
65
67
|
agentToolRuntime,
|
|
68
|
+
pluginNotificationEmitter,
|
|
66
69
|
desktopUpdater,
|
|
70
|
+
skipBodyParsing,
|
|
67
71
|
} = options;
|
|
68
72
|
|
|
69
73
|
const uiAuthController = createUiAuth({
|
|
@@ -90,10 +94,17 @@ export const createBootstrapRuntime = (dependencies) => {
|
|
|
90
94
|
uiAuthController,
|
|
91
95
|
});
|
|
92
96
|
|
|
93
|
-
registerCommonRequestMiddleware(app, { express, verboseRequestLogs });
|
|
97
|
+
registerCommonRequestMiddleware(app, { express, verboseRequestLogs, skipBodyParsing });
|
|
94
98
|
|
|
95
99
|
registerAgentToolRoutes(app, { express, agentToolRuntime });
|
|
96
100
|
|
|
101
|
+
const notificationEmitRoutes = registerNotificationEmitRoutes(app, {
|
|
102
|
+
express,
|
|
103
|
+
isAgentToolRequestAuthorized: (req) => agentToolRuntime?.authorizeRequest?.(req) === true,
|
|
104
|
+
emitter: pluginNotificationEmitter,
|
|
105
|
+
});
|
|
106
|
+
notificationEmitRoutes.registerPluginRoute();
|
|
107
|
+
|
|
97
108
|
registerAuthAndAccessRoutes(app, {
|
|
98
109
|
express,
|
|
99
110
|
tunnelAuthController,
|
|
@@ -110,6 +121,8 @@ export const createBootstrapRuntime = (dependencies) => {
|
|
|
110
121
|
normalizeTunnelSessionTtlMs,
|
|
111
122
|
});
|
|
112
123
|
|
|
124
|
+
notificationEmitRoutes.registerApiRoute();
|
|
125
|
+
|
|
113
126
|
registerTtsRoutes(app, { sayTTSCapability });
|
|
114
127
|
|
|
115
128
|
registerNotificationRoutes(app, {
|
|
@@ -63,13 +63,16 @@ export const requireOpenCodeV2 = async (launch, options) => {
|
|
|
63
63
|
|
|
64
64
|
// External URLs have no local executable. A legacy probe identifies v1 only
|
|
65
65
|
// from its JSON contract; neither HTML fallbacks nor auth failures imply v1.
|
|
66
|
+
// v1 has no `/api/info`: its catch-all serves the web UI, and a v1 build
|
|
67
|
+
// without the embedded UI proxies that path to app.opencode.ai, which can hang
|
|
68
|
+
// or fail. A failed v2 probe therefore still falls through to the v1 probe.
|
|
66
69
|
export const readExternalOpenCodeVersion = async (baseUrl, headers, fetchImpl = fetch) => {
|
|
67
70
|
const request = (pathname) => fetchImpl(new URL(pathname, baseUrl), {
|
|
68
71
|
headers: { ...headers, Accept: 'application/json' }, redirect: 'error', signal: AbortSignal.timeout(5000),
|
|
69
72
|
});
|
|
70
|
-
const response = await request('/api/info');
|
|
71
|
-
if (response.status === 401 || response.status === 403) return null;
|
|
72
|
-
const info = await readOpenCodeInfo(response);
|
|
73
|
+
const response = await request('/api/info').catch(() => null);
|
|
74
|
+
if (response && (response.status === 401 || response.status === 403)) return null;
|
|
75
|
+
const info = response ? await readOpenCodeInfo(response) : null;
|
|
73
76
|
if (info) return info.version;
|
|
74
77
|
const legacy = await request('/global/health');
|
|
75
78
|
if (!legacy.ok) return null;
|
|
@@ -30,6 +30,14 @@ describe('OpenCode compatibility', () => {
|
|
|
30
30
|
expect(requests).toEqual(['/api/info', '/global/health']);
|
|
31
31
|
});
|
|
32
32
|
|
|
33
|
+
it('still identifies v1 when its /api/info fallback hangs or fails', async () => {
|
|
34
|
+
const fetchImpl = async (url) => {
|
|
35
|
+
if (url.pathname === '/api/info') throw new DOMException('The operation timed out.', 'TimeoutError');
|
|
36
|
+
return Response.json({ healthy: true, version: '1.18.32' });
|
|
37
|
+
};
|
|
38
|
+
expect(await readExternalOpenCodeVersion('http://localhost:4096', {}, fetchImpl)).toBe('1.18.32');
|
|
39
|
+
});
|
|
40
|
+
|
|
33
41
|
it.each([401, 403])('does not call an auth failure v1 (%s)', async (status) => {
|
|
34
42
|
let requests = 0;
|
|
35
43
|
expect(await readExternalOpenCodeVersion('http://localhost:4096', {}, async () => {
|
|
@@ -261,6 +261,33 @@ describe('entity modules speak OpenCode 2 shapes', () => {
|
|
|
261
261
|
}
|
|
262
262
|
});
|
|
263
263
|
|
|
264
|
+
it('lists servers from a global opencode.jsonc next to opencode.json and edits them in place', () => {
|
|
265
|
+
const configDir = path.join(process.env.XDG_CONFIG_HOME, 'opencode');
|
|
266
|
+
const jsonPath = path.join(configDir, 'opencode.json');
|
|
267
|
+
const jsoncPath = path.join(configDir, 'opencode.jsonc');
|
|
268
|
+
fs.mkdirSync(configDir, { recursive: true });
|
|
269
|
+
fs.writeFileSync(jsonPath, JSON.stringify({ autoupdate: true }, null, 2), 'utf8');
|
|
270
|
+
fs.writeFileSync(jsoncPath, [
|
|
271
|
+
'{',
|
|
272
|
+
' // servers live only in the jsonc file',
|
|
273
|
+
' "mcp": { "servers": { "docs": { "type": "remote", "url": "https://docs.example.com/mcp" } } }',
|
|
274
|
+
'}',
|
|
275
|
+
].join('\n'), 'utf8');
|
|
276
|
+
|
|
277
|
+
try {
|
|
278
|
+
expect(listMcpConfigs(projectDir).map((entry) => entry.name)).toContain('docs');
|
|
279
|
+
expect(getMcpConfig('docs', projectDir)).toEqual(expect.objectContaining({ scope: 'user' }));
|
|
280
|
+
|
|
281
|
+
updateMcpConfig('docs', { disabled: true }, projectDir);
|
|
282
|
+
|
|
283
|
+
expect(getMcpConfig('docs', projectDir)).toEqual(expect.objectContaining({ disabled: true }));
|
|
284
|
+
expect(readJson(jsonPath)).toEqual({ autoupdate: true });
|
|
285
|
+
} finally {
|
|
286
|
+
fs.rmSync(jsonPath, { force: true });
|
|
287
|
+
fs.rmSync(jsoncPath, { force: true });
|
|
288
|
+
}
|
|
289
|
+
});
|
|
290
|
+
|
|
264
291
|
it('reads a v1 mcp entry and rewrites it under mcp.servers in the same file', () => {
|
|
265
292
|
const configPath = write('opencode.json', JSON.stringify({
|
|
266
293
|
mcp: {
|
|
@@ -111,6 +111,18 @@ describe('OpenCode global config paths', () => {
|
|
|
111
111
|
expect(response.json).toHaveBeenLastCalledWith({
|
|
112
112
|
content: 'Global behavior', exists: true, path: path.join(process.env.XDG_CONFIG_HOME, 'opencode', 'AGENTS.md'),
|
|
113
113
|
});
|
|
114
|
+
|
|
115
|
+
// A write based on an outdated copy is refused and leaves the file alone.
|
|
116
|
+
await handlers.get('PUT /api/behavior/agents-md')({
|
|
117
|
+
body: { content: 'Stale draft', expectedContent: 'Older behavior' },
|
|
118
|
+
}, response);
|
|
119
|
+
expect(response.status).toHaveBeenLastCalledWith(409);
|
|
120
|
+
expect(fs.readFileSync(path.join(process.env.XDG_CONFIG_HOME, 'opencode', 'AGENTS.md'), 'utf8')).toBe('Global behavior');
|
|
121
|
+
|
|
122
|
+
await handlers.get('PUT /api/behavior/agents-md')({
|
|
123
|
+
body: { content: 'Current draft', expectedContent: 'Global behavior' },
|
|
124
|
+
}, response);
|
|
125
|
+
expect(fs.readFileSync(path.join(process.env.XDG_CONFIG_HOME, 'opencode', 'AGENTS.md'), 'utf8')).toBe('Current draft');
|
|
114
126
|
fs.rmSync(root, { recursive: true, force: true });
|
|
115
127
|
});
|
|
116
128
|
});
|
|
@@ -162,6 +162,7 @@ export function deleteMcpEntry(config: Record<string, unknown>, name: string): b
|
|
|
162
162
|
export function toProviderPackage(value: unknown): string | undefined;
|
|
163
163
|
export function toNpmPackage(value: unknown): string | undefined;
|
|
164
164
|
export function toProviderEntity(raw: unknown): ProviderEntity;
|
|
165
|
+
export function readStoredProviderEntry(configs: unknown[], providerId: string): ProviderEntity | null;
|
|
165
166
|
|
|
166
167
|
export function toPluginEntity(raw: unknown): PluginEntity | null;
|
|
167
168
|
export function fromPluginEntity(entity: unknown): string | PluginEntity | null;
|
|
@@ -171,6 +172,7 @@ export function readPluginList(config: unknown): Array<{ entry: PluginEntity; ke
|
|
|
171
172
|
export type WebSearchSelection = false | string | null;
|
|
172
173
|
export function parseWebSearchSelection(value: unknown): WebSearchSelection | undefined;
|
|
173
174
|
export function writeWebSearchSelection(config: Record<string, unknown>, selection: WebSearchSelection): boolean;
|
|
175
|
+
export function writeWarmingEnabled(config: Record<string, unknown>, enabled: boolean): boolean;
|
|
174
176
|
export interface WebSearchConfigLayers {
|
|
175
177
|
userConfig: object | null;
|
|
176
178
|
projectConfig: object | null;
|
|
@@ -668,6 +668,28 @@ function toProviderEntity(raw) {
|
|
|
668
668
|
]);
|
|
669
669
|
}
|
|
670
670
|
|
|
671
|
+
/**
|
|
672
|
+
* The provider entry an editor should start from: the winning config layer's
|
|
673
|
+
* stored entry (layers passed highest precedence first) in v2 shape, without
|
|
674
|
+
* the literal `settings.apiKey` secret. Returns null when no layer defines it.
|
|
675
|
+
* Unlike the live provider OpenCode serves, this carries `env` and only the
|
|
676
|
+
* reasoning levels the user wrote.
|
|
677
|
+
*/
|
|
678
|
+
function readStoredProviderEntry(configs, providerId) {
|
|
679
|
+
for (const config of configs) {
|
|
680
|
+
const { value } = readSectionEntry(config, 'providers', providerId);
|
|
681
|
+
if (value === undefined) continue;
|
|
682
|
+
const entity = toProviderEntity(value);
|
|
683
|
+
if (entity.settings && 'apiKey' in entity.settings) {
|
|
684
|
+
const { apiKey: _secret, ...settings } = entity.settings;
|
|
685
|
+
if (Object.keys(settings).length) entity.settings = settings;
|
|
686
|
+
else delete entity.settings;
|
|
687
|
+
}
|
|
688
|
+
return entity;
|
|
689
|
+
}
|
|
690
|
+
return null;
|
|
691
|
+
}
|
|
692
|
+
|
|
671
693
|
// ============== PLUGINS ==============
|
|
672
694
|
|
|
673
695
|
const PLUGIN_SECTION = { v2: 'plugins', v1: 'plugin' };
|
|
@@ -742,6 +764,24 @@ function writeWebSearchSelection(config, selection) {
|
|
|
742
764
|
return JSON.stringify(config.websearch) !== before;
|
|
743
765
|
}
|
|
744
766
|
|
|
767
|
+
// ============== SESSION WARMING ==============
|
|
768
|
+
|
|
769
|
+
/**
|
|
770
|
+
* The `warming` key: `true` keeps idle sessions' prompt cache warm with
|
|
771
|
+
* OpenCode's defaults, an object tunes prompt/interval/duration. Turning it on
|
|
772
|
+
* keeps a hand-tuned object as is; turning it off removes the key (OpenCode's
|
|
773
|
+
* default is off). Returns whether the config changed.
|
|
774
|
+
*/
|
|
775
|
+
function writeWarmingEnabled(config, enabled) {
|
|
776
|
+
const before = JSON.stringify(config.warming);
|
|
777
|
+
if (!enabled) {
|
|
778
|
+
delete config.warming;
|
|
779
|
+
} else if (typeof config.warming !== 'object' || config.warming === null) {
|
|
780
|
+
config.warming = true;
|
|
781
|
+
}
|
|
782
|
+
return JSON.stringify(config.warming) !== before;
|
|
783
|
+
}
|
|
784
|
+
|
|
745
785
|
const hasWebSearchKey = (config) => config != null && Object.hasOwn(config, 'websearch');
|
|
746
786
|
|
|
747
787
|
/**
|
|
@@ -792,10 +832,12 @@ export {
|
|
|
792
832
|
toProviderPackage,
|
|
793
833
|
toNpmPackage,
|
|
794
834
|
toProviderEntity,
|
|
835
|
+
readStoredProviderEntry,
|
|
795
836
|
toPluginEntity,
|
|
796
837
|
fromPluginEntity,
|
|
797
838
|
readPluginList,
|
|
798
839
|
parseWebSearchSelection,
|
|
799
840
|
writeWebSearchSelection,
|
|
800
841
|
findWebSearchProjectOverride,
|
|
842
|
+
writeWarmingEnabled,
|
|
801
843
|
};
|
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
parseModelSelection,
|
|
10
10
|
formatModelSelection,
|
|
11
11
|
toAgentEntity,
|
|
12
|
+
writeWarmingEnabled,
|
|
12
13
|
fromAgentEntity,
|
|
13
14
|
isLegacyAgentFrontmatter,
|
|
14
15
|
toCommandEntity,
|
|
@@ -464,3 +465,19 @@ describe('findWebSearchProjectOverride', () => {
|
|
|
464
465
|
expect(findWebSearchProjectOverride(layers(), [{ path: userPath, config: { websearch: false } }])).toBeNull();
|
|
465
466
|
});
|
|
466
467
|
});
|
|
468
|
+
|
|
469
|
+
describe('session warming', () => {
|
|
470
|
+
it('turns warming on and off, keeping a hand-tuned object', () => {
|
|
471
|
+
const config = { model: 'openai/gpt-5' };
|
|
472
|
+
expect(writeWarmingEnabled(config, true)).toBe(true);
|
|
473
|
+
expect(config.warming).toBe(true);
|
|
474
|
+
expect(writeWarmingEnabled(config, true)).toBe(false);
|
|
475
|
+
expect(writeWarmingEnabled(config, false)).toBe(true);
|
|
476
|
+
expect(config).toEqual({ model: 'openai/gpt-5' });
|
|
477
|
+
expect(writeWarmingEnabled(config, false)).toBe(false);
|
|
478
|
+
|
|
479
|
+
const tuned = { warming: { interval: '3 minutes' } };
|
|
480
|
+
expect(writeWarmingEnabled(tuned, true)).toBe(false);
|
|
481
|
+
expect(tuned.warming).toEqual({ interval: '3 minutes' });
|
|
482
|
+
});
|
|
483
|
+
});
|
|
@@ -94,6 +94,7 @@ export const registerServerStatusRoutes = (app, dependencies) => {
|
|
|
94
94
|
'api.health.v1',
|
|
95
95
|
'api.runtime-url.v1',
|
|
96
96
|
'api.raw-file.v1',
|
|
97
|
+
'api.notifications.emit.v1',
|
|
97
98
|
'realtime.sse.v1',
|
|
98
99
|
'realtime.websocket.global-events.v1',
|
|
99
100
|
'terminal.websocket.v1',
|
|
@@ -1074,10 +1075,15 @@ export const registerSettingsUtilityRoutes = (app, dependencies) => {
|
|
|
1074
1075
|
};
|
|
1075
1076
|
|
|
1076
1077
|
export const registerCommonRequestMiddleware = (app, dependencies) => {
|
|
1077
|
-
|
|
1078
|
+
// `skipBodyParsing(req)` names a request whose body must reach its route untouched: a
|
|
1079
|
+
// request the isolated-spaces dispatcher streams into a space, where a parsed body would
|
|
1080
|
+
// otherwise be consumed here and lost.
|
|
1081
|
+
const { express, verboseRequestLogs = false, skipBodyParsing = () => false } = dependencies;
|
|
1078
1082
|
|
|
1079
1083
|
app.use((req, res, next) => {
|
|
1080
|
-
if (req
|
|
1084
|
+
if (skipBodyParsing(req)) {
|
|
1085
|
+
next();
|
|
1086
|
+
} else if (req.path === '/api/config/themes' || req.path.startsWith('/api/config/themes/')) {
|
|
1081
1087
|
express.json({ limit: '1mb' })(req, res, next);
|
|
1082
1088
|
} else if (req.path.startsWith('/api/behavior')) {
|
|
1083
1089
|
const contentLength = parseInt(req.headers['content-length'] || '0', 10);
|
|
@@ -1093,6 +1099,8 @@ export const registerCommonRequestMiddleware = (app, dependencies) => {
|
|
|
1093
1099
|
req.path.startsWith('/api/config/settings') ||
|
|
1094
1100
|
req.path.startsWith('/api/config/skills') ||
|
|
1095
1101
|
req.path.startsWith('/api/config/plugins') ||
|
|
1102
|
+
req.path.startsWith('/api/config/websearch') ||
|
|
1103
|
+
req.path.startsWith('/api/config/warming') ||
|
|
1096
1104
|
req.path.startsWith('/api/projects') ||
|
|
1097
1105
|
req.path.startsWith('/api/fs') ||
|
|
1098
1106
|
req.path.startsWith('/api/git') ||
|
|
@@ -1112,7 +1120,8 @@ export const registerCommonRequestMiddleware = (app, dependencies) => {
|
|
|
1112
1120
|
req.path.startsWith('/api/text') ||
|
|
1113
1121
|
req.path.startsWith('/api/voice') ||
|
|
1114
1122
|
req.path.startsWith('/api/tts') ||
|
|
1115
|
-
req.path.startsWith('/api/openchamber/tunnel')
|
|
1123
|
+
req.path.startsWith('/api/openchamber/tunnel') ||
|
|
1124
|
+
req.path.startsWith('/api/openchamber/spaces')
|
|
1116
1125
|
) {
|
|
1117
1126
|
express.json({ limit: '50mb' })(req, res, next);
|
|
1118
1127
|
} else if (req.path.startsWith('/api')) {
|
|
@@ -1122,7 +1131,14 @@ export const registerCommonRequestMiddleware = (app, dependencies) => {
|
|
|
1122
1131
|
}
|
|
1123
1132
|
});
|
|
1124
1133
|
|
|
1125
|
-
|
|
1134
|
+
const urlencoded = express.urlencoded({ extended: true, limit: '50mb' });
|
|
1135
|
+
app.use((req, res, next) => {
|
|
1136
|
+
if (skipBodyParsing(req)) {
|
|
1137
|
+
next();
|
|
1138
|
+
return;
|
|
1139
|
+
}
|
|
1140
|
+
urlencoded(req, res, next);
|
|
1141
|
+
});
|
|
1126
1142
|
|
|
1127
1143
|
app.use((req, _res, next) => {
|
|
1128
1144
|
if (verboseRequestLogs) {
|
|
@@ -127,6 +127,47 @@ describe('core-routes', () => {
|
|
|
127
127
|
expect(response.body).toEqual({ body: { content: 'Snippet body' } });
|
|
128
128
|
});
|
|
129
129
|
|
|
130
|
+
it('leaves the body of a request alone when told to, for both parsers', async () => {
|
|
131
|
+
const app = express();
|
|
132
|
+
registerCommonRequestMiddleware(app, { express, skipBodyParsing: (req) => req.path.startsWith('/api/spaces/') });
|
|
133
|
+
const raw = (req, res) => {
|
|
134
|
+
// A body a parser read is complete already, and its stream ends no second time.
|
|
135
|
+
if (req.complete) {
|
|
136
|
+
res.json({ body: req.body ?? null, raw: '' });
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
const chunks = [];
|
|
140
|
+
req.on('data', (chunk) => chunks.push(chunk));
|
|
141
|
+
req.on('end', () => res.json({ body: req.body ?? null, raw: Buffer.concat(chunks).toString('utf8') }));
|
|
142
|
+
};
|
|
143
|
+
app.post('/api/spaces/abc/form', raw);
|
|
144
|
+
app.post('/api/spaces/abc/config/snippets/x', raw);
|
|
145
|
+
app.post('/api/other/form', raw);
|
|
146
|
+
|
|
147
|
+
const kept = await request(app).post('/api/spaces/abc/form').type('form').send('a=1&b=2').expect(200);
|
|
148
|
+
expect(kept.body).toEqual({ body: null, raw: 'a=1&b=2' });
|
|
149
|
+
const keptJson = await request(app).post('/api/spaces/abc/config/snippets/x').send({ content: 'x' }).expect(200);
|
|
150
|
+
expect(keptJson.body).toEqual({ body: null, raw: '{"content":"x"}' });
|
|
151
|
+
// Elsewhere the parsers still run, and a parsed body leaves nothing to read.
|
|
152
|
+
const parsed = await request(app).post('/api/other/form').type('form').send('a=1&b=2').expect(200);
|
|
153
|
+
expect(parsed.body).toEqual({ body: { a: '1', b: '2' }, raw: '' });
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it('should parse JSON bodies for the web search config route', async () => {
|
|
157
|
+
const app = express();
|
|
158
|
+
registerCommonRequestMiddleware(app, { express });
|
|
159
|
+
app.put('/api/config/websearch', (req, res) => {
|
|
160
|
+
res.json({ body: req.body });
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
const response = await request(app)
|
|
164
|
+
.put('/api/config/websearch')
|
|
165
|
+
.send({ selection: 'random' })
|
|
166
|
+
.expect(200);
|
|
167
|
+
|
|
168
|
+
expect(response.body).toEqual({ body: { selection: 'random' } });
|
|
169
|
+
});
|
|
170
|
+
|
|
130
171
|
it('should parse JSON bodies for custom provider upsert routes', async () => {
|
|
131
172
|
const app = express();
|
|
132
173
|
registerCommonRequestMiddleware(app, { express });
|
|
@@ -698,15 +698,8 @@ export const createOpenCodeEnvRuntime = (deps) => {
|
|
|
698
698
|
];
|
|
699
699
|
|
|
700
700
|
const getWindowsNativeOpencodePackageNames = () => {
|
|
701
|
-
// TEMPORARY WORKAROUND — Windows ARM64: native opencode.exe fails with a Bun
|
|
702
|
-
// FFI/TinyCC dlopen error (https://github.com/anomalyco/opencode/issues/19130).
|
|
703
|
-
// prepare-opencode-cli.mjs bundles x64-baseline instead; match that here so
|
|
704
|
-
// the runtime resolver looks for the same x64-baseline package. Restore the
|
|
705
|
-
// arm64 branch below when the upstream issue is resolved.
|
|
706
701
|
if (process.arch === 'arm64') {
|
|
707
|
-
|
|
708
|
-
// return ['@opencode/cli-windows-arm64', 'opencode-windows-arm64'];
|
|
709
|
-
return WINDOWS_X64_NATIVE_PACKAGES;
|
|
702
|
+
return [path.join('@opencode', 'cli-windows-arm64'), 'opencode-windows-arm64'];
|
|
710
703
|
}
|
|
711
704
|
if (process.arch === 'x64') {
|
|
712
705
|
// Prefer the baseline build when bypassing package-manager wrappers so the
|
|
@@ -103,6 +103,7 @@ export const createFeatureRoutesRuntime = (dependencies) => {
|
|
|
103
103
|
createFsSearchRuntime,
|
|
104
104
|
openchamberDataDir,
|
|
105
105
|
onGuestDeactivated,
|
|
106
|
+
surfaceViewerHeaders,
|
|
106
107
|
openchamberUserConfigRoot,
|
|
107
108
|
managedChatsRoot,
|
|
108
109
|
normalizeDirectoryPath,
|
|
@@ -331,7 +332,7 @@ export const createFeatureRoutesRuntime = (dependencies) => {
|
|
|
331
332
|
registerGitHubRoutes(app);
|
|
332
333
|
registerLinearRoutes(app);
|
|
333
334
|
await registerBuiltInGuests({ persistPath: extensionsPersistPath(openchamberDataDir), root: routeDependencies.builtInExtensionsDir });
|
|
334
|
-
registerGuestRoutes(app, { openchamberDataDir, openchamberVersion, resolveGitBinaryForSpawn, resolveOptionalProjectDirectory, getSmallModelService, onGuestDeactivated });
|
|
335
|
+
registerGuestRoutes(app, { openchamberDataDir, openchamberVersion, resolveGitBinaryForSpawn, resolveOptionalProjectDirectory, getSmallModelService, onGuestDeactivated, surfaceViewerHeaders });
|
|
335
336
|
registerGitRoutes(app, {
|
|
336
337
|
emitWorktreeChanged: ({ directories, at }) => {
|
|
337
338
|
const clients = getOpenChamberEventClients();
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { readOpenCodeInfo, isSupportedOpenCodeVersion, requireOpenCodeV2, UnsupportedOpenCodeVersionError } from './compatibility.js';
|
|
1
|
+
import { readOpenCodeInfo, readExternalOpenCodeVersion, isSupportedOpenCodeVersion, requireOpenCodeV2, UnsupportedOpenCodeVersionError } from './compatibility.js';
|
|
2
2
|
import { spawn, spawnSync } from 'node:child_process';
|
|
3
3
|
import net from 'node:net';
|
|
4
4
|
import { stripAppImageArgv0Leak } from '../inherited-env.js';
|
|
@@ -803,6 +803,16 @@ export const createOpenCodeLifecycleRuntime = (deps) => {
|
|
|
803
803
|
}
|
|
804
804
|
};
|
|
805
805
|
|
|
806
|
+
// The version of an explicitly configured server that answered but is not a
|
|
807
|
+
// supported OpenCode (v1, or 2.x below the minimum); null for anything else,
|
|
808
|
+
// including a server that is down.
|
|
809
|
+
const readUnsupportedExternalOpenCodeVersion = async (port, origin) => {
|
|
810
|
+
if (!port || port <= 0) return null;
|
|
811
|
+
const version = await readExternalOpenCodeVersion(origin ?? `http://127.0.0.1:${port}`, getOpenCodeAuthHeaders())
|
|
812
|
+
.catch(() => null);
|
|
813
|
+
return version && !isSupportedOpenCodeVersion(version) ? version : null;
|
|
814
|
+
};
|
|
815
|
+
|
|
806
816
|
const waitForOpenCodePort = async (timeoutMs = 15000) => {
|
|
807
817
|
if (state.openCodePort !== null) {
|
|
808
818
|
return state.openCodePort;
|
|
@@ -1117,7 +1127,7 @@ export const createOpenCodeLifecycleRuntime = (deps) => {
|
|
|
1117
1127
|
}
|
|
1118
1128
|
|
|
1119
1129
|
const info = await readOpenCodeInfo(response);
|
|
1120
|
-
if (!info) throw new Error('
|
|
1130
|
+
if (!info) throw new Error('The server did not identify itself as OpenCode 2.x. OpenChamber requires OpenCode 2.x; if the server runs an older OpenCode, upgrade it.');
|
|
1121
1131
|
if (!isSupportedOpenCodeVersion(info.version)) throw new UnsupportedOpenCodeVersionError(info.version);
|
|
1122
1132
|
state.isOpenCodeReady = true;
|
|
1123
1133
|
state.lastOpenCodeError = null;
|
|
@@ -1216,6 +1226,7 @@ export const createOpenCodeLifecycleRuntime = (deps) => {
|
|
|
1216
1226
|
const bootstrapOpenCodeAtStartup = async () => {
|
|
1217
1227
|
const bootstrapStartedAt = performance.now();
|
|
1218
1228
|
let bootstrapError = null;
|
|
1229
|
+
let unsupportedExternalVersion = null;
|
|
1219
1230
|
recordStartupPerformance('opencode.bootstrap.start');
|
|
1220
1231
|
try {
|
|
1221
1232
|
// Before doing anything, reap any OpenCode process WE spawned in a prior
|
|
@@ -1256,6 +1267,20 @@ export const createOpenCodeLifecycleRuntime = (deps) => {
|
|
|
1256
1267
|
state.lastOpenCodeError = null;
|
|
1257
1268
|
state.openCodeNotReadySince = 0;
|
|
1258
1269
|
syncToHmrState();
|
|
1270
|
+
} else if (env.ENV_EFFECTIVE_PORT && (unsupportedExternalVersion = await readUnsupportedExternalOpenCodeVersion(
|
|
1271
|
+
env.ENV_EFFECTIVE_PORT,
|
|
1272
|
+
env.ENV_CONFIGURED_OPENCODE_HOST?.origin,
|
|
1273
|
+
))) {
|
|
1274
|
+
// The configured server is an OpenCode this OpenChamber cannot use.
|
|
1275
|
+
// Attach to it anyway, not ready, so the compatibility check reports
|
|
1276
|
+
// its version instead of a managed instance silently replacing it.
|
|
1277
|
+
const label = env.ENV_CONFIGURED_OPENCODE_HOST ? env.ENV_CONFIGURED_OPENCODE_HOST.origin : `http://localhost:${env.ENV_EFFECTIVE_PORT}`;
|
|
1278
|
+
console.warn(`OpenCode ${unsupportedExternalVersion} at ${label} is not supported by this OpenChamber`);
|
|
1279
|
+
state.openCodeBaseUrl = env.ENV_CONFIGURED_OPENCODE_HOST?.origin ?? null;
|
|
1280
|
+
setOpenCodePort(env.ENV_EFFECTIVE_PORT);
|
|
1281
|
+
state.isExternalOpenCode = true;
|
|
1282
|
+
syncToHmrState();
|
|
1283
|
+
throw new UnsupportedOpenCodeVersionError(unsupportedExternalVersion);
|
|
1259
1284
|
} else {
|
|
1260
1285
|
// We never auto-attach to an arbitrary pre-existing OpenCode instance.
|
|
1261
1286
|
// Attaching to an external server requires explicit opt-in via env
|
|
@@ -1282,6 +1307,9 @@ export const createOpenCodeLifecycleRuntime = (deps) => {
|
|
|
1282
1307
|
await waitForOpenCodeReady();
|
|
1283
1308
|
} catch (error) {
|
|
1284
1309
|
bootstrapError = error;
|
|
1310
|
+
// Skip-start mode assumed readiness up front; a server that never
|
|
1311
|
+
// proved itself must not keep reporting ready to startup diagnostics.
|
|
1312
|
+
state.isOpenCodeReady = false;
|
|
1285
1313
|
console.error(`OpenCode readiness check failed: ${error.message}`);
|
|
1286
1314
|
}
|
|
1287
1315
|
} catch (error) {
|
|
@@ -467,6 +467,33 @@ describe('OpenCode lifecycle', () => {
|
|
|
467
467
|
expect(runtime.testState.openCodeBaseUrl).toBe('http://seamus:4095');
|
|
468
468
|
});
|
|
469
469
|
|
|
470
|
+
it('attaches to an OPENCODE_HOST running OpenCode v1 instead of starting a managed server', async () => {
|
|
471
|
+
const requested = [];
|
|
472
|
+
globalThis.fetch = vi.fn(async (url) => {
|
|
473
|
+
const href = String(url);
|
|
474
|
+
requested.push(href);
|
|
475
|
+
if (href.endsWith('/global/health')) return Response.json({ healthy: true, version: '1.18.32' });
|
|
476
|
+
return new Response('<html>OpenCode</html>', { headers: { 'content-type': 'text/html' } });
|
|
477
|
+
});
|
|
478
|
+
const runtime = createRuntime({
|
|
479
|
+
reapManagedOrphanedProcesses: vi.fn(async () => ({ reaped: 0 })),
|
|
480
|
+
}, {}, {
|
|
481
|
+
ENV_CONFIGURED_OPENCODE_PORT: null,
|
|
482
|
+
ENV_CONFIGURED_OPENCODE_HOST: { origin: 'http://seamus:4095', port: 4095 },
|
|
483
|
+
ENV_EFFECTIVE_PORT: 4095,
|
|
484
|
+
});
|
|
485
|
+
|
|
486
|
+
await runtime.bootstrapOpenCodeAtStartup();
|
|
487
|
+
|
|
488
|
+
expect(spawnMock).not.toHaveBeenCalled();
|
|
489
|
+
expect(requested).toContain('http://seamus:4095/global/health');
|
|
490
|
+
expect(runtime.testState.isExternalOpenCode).toBe(true);
|
|
491
|
+
expect(runtime.testState.isOpenCodeReady).toBe(false);
|
|
492
|
+
expect(runtime.testState.openCodeBaseUrl).toBe('http://seamus:4095');
|
|
493
|
+
expect(runtime.testState.openCodePort).toBe(4095);
|
|
494
|
+
expect(runtime.testState.lastOpenCodeError).toContain('1.18.32');
|
|
495
|
+
});
|
|
496
|
+
|
|
470
497
|
it('warms recently used directories after a successful bootstrap', async () => {
|
|
471
498
|
const fetchMock = vi.fn(async () => ({
|
|
472
499
|
ok: true,
|