@vantran-se/openchamber-web 1.22.1 → 1.23.0
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 +2 -3
- package/bin/cli.js +11 -1
- package/bin/cli.test.js +92 -0
- package/bin/lib/cli-startup.js +49 -0
- package/bin/lib/commands-startup.js +41 -6
- package/dist/assets/{CodeMirrorCommentWidgets-C53Q7pbX.js → CodeMirrorCommentWidgets-DFg2wnpo.js} +1 -1
- package/dist/assets/DiffView-3Dn4QHbu.js +4 -0
- package/dist/assets/DirtyBranchSwitchDialog-CwOerqVi.js +1 -0
- package/dist/assets/FilesView-BdTOpew9.js +17 -0
- package/dist/assets/GitView-Dqf9vHJh.js +6 -0
- package/dist/assets/{JsonTreeView-Bl0lp9Rs.js → JsonTreeView-BKdsjg5B.js} +1 -1
- package/dist/assets/LinearIssuesView-CmbeL7I6.js +1 -0
- package/dist/assets/MarkdownImageGallery-_5gEVr0X.js +1 -0
- package/dist/assets/MarkdownRendererImpl-wVZlx4dd.js +12 -0
- package/dist/assets/OnboardingScreen-DdP4_nuc.js +1 -0
- package/dist/assets/{PatchDiff-D6NXAfJk.js → PatchDiff-am3SAss2.js} +1 -1
- package/dist/assets/PierreDiffViewer-DCsydlKs.js +161 -0
- package/dist/assets/PlanView-BoJxdRZe.js +5 -0
- package/dist/assets/SettingsView-CcM8pF5O.js +28 -0
- package/dist/assets/{SettingsWindow-BiWDBRvb.js → SettingsWindow-DVTdDSol.js} +1 -1
- package/dist/assets/SymbolsNerdFontMono-Regular-aK5vsLov.woff2 +0 -0
- package/dist/assets/{ToolOutputDialog-BQexMQIm.js → ToolOutputDialog-BgfTbDZ4.js} +6 -6
- package/dist/assets/ToolPartDiffPreview-C7WLk5i9.js +8 -0
- package/dist/assets/{VirtualizedFileDiff-Dpz2ra8h.js → VirtualizedFileDiff-DcdgkH-x.js} +1 -1
- package/dist/assets/WalkthroughView-BEVUDxuw.js +4 -0
- package/dist/assets/{computeVirtualFileMetrics-DtyozJdt.js → computeVirtualFileMetrics-C8byMnRh.js} +2 -2
- package/dist/assets/contextPanelEmbeddedChat-CirXEPJq.js +1 -0
- package/dist/assets/de-C8jV7J9y.js +10 -0
- package/dist/assets/es-BI6J1M5R.js +16 -0
- package/dist/assets/fr-D6oDFt7E.js +14 -0
- package/dist/assets/ghostty-vt-DdA0Zryv.wasm +0 -0
- package/dist/assets/{index-DmhujXbT.js → index-BB7ryz8y.js} +1 -1
- package/dist/assets/index-BqhiCmYS.js +39 -0
- package/dist/assets/index-FqSOBz0J.css +1 -0
- package/dist/assets/{isDiffPlainText-XyulMHxx.js → isDiffPlainText-45ujMPGb.js} +1 -1
- package/dist/assets/{isFilePlainText-B19Ddps1.js → isFilePlainText-Cc4KFSYi.js} +1 -1
- package/dist/assets/ja-C_4i5EQu.js +16 -0
- package/dist/assets/ko-BFbOTpbq.js +16 -0
- package/dist/assets/{main-B2a_JfKZ.js → main-C9ETLKFc.js} +2 -2
- package/dist/assets/main-CJmb2dF8.js +930 -0
- package/dist/assets/markdownCore-DVuZpnli.js +5 -0
- package/dist/assets/{miniChat-B3CSU_Ps.js → miniChat-B4Ytoq2n.js} +2 -2
- package/dist/assets/{mobile-QNcFIVCW.js → mobile-BUT6-lo1.js} +2 -2
- package/dist/assets/patchFileDiff-8j1apICU.js +13 -0
- package/dist/assets/pl-COFvT56l.js +16 -0
- package/dist/assets/pt-BR-DtVOO2Lj.js +16 -0
- package/dist/assets/renderElectronMiniChatApp-B6vUG-vF.js +2 -0
- package/dist/assets/renderMobileApp-BNgHraq6.js +25 -0
- package/dist/assets/runtimeEndpointReset-B8LYpWvr.js +17 -0
- package/dist/assets/scheduledTasksApi-GC4epIYA.js +1 -0
- package/dist/assets/{shikiHighlight-DE-OwqGw.js → shikiHighlight-BJISeZsQ.js} +1 -1
- package/dist/assets/surface-BvRzS58U.js +1 -0
- package/dist/assets/tr-B8Q0Go2l.js +7 -0
- package/dist/assets/uk-Dk4a-Fn-.js +16 -0
- package/dist/assets/{useAppFontEffects-COAeIX-D.js → useAppFontEffects-DpFNefUA.js} +153 -179
- package/dist/assets/useGitComparison-Dfxaqd0s.js +1 -0
- package/dist/assets/useInlineCommentController-CscsE4t-.js +2 -0
- package/dist/assets/useRootScrollLock-BaCvuRlT.js +1 -0
- package/dist/assets/{vendor-base-ui-BeMD8X_w.js → vendor-base-ui-DdV8vhHn.js} +1 -1
- package/dist/assets/{vendor-codemirror-language-data-BG3v1Ghq.js → vendor-codemirror-language-data-DzlSIu_W.js} +2 -2
- package/dist/assets/vendor-legendapp-list-BuBPBC3R.js +1 -0
- package/dist/assets/vendor-zod-BPAANqvH.js +39 -0
- package/dist/assets/{web-components-BNMwa0qm.js → web-components-CdGfR2XJ.js} +1 -1
- package/dist/assets/zh-CN-B_BXxG1Q.js +16 -0
- package/dist/assets/zh-TW-DLrl1SPZ.js +16 -0
- package/dist/index.html +5 -43
- package/dist/mini-chat.html +4 -4
- package/dist/mobile.html +4 -4
- package/package.json +8 -5
- package/server/index.d.ts +15 -0
- package/server/index.js +82 -13
- package/server/lib/agent-tool/runtime.js +2 -19
- package/server/lib/changelog/update-notes.js +82 -0
- package/server/lib/changelog/update-notes.test.js +59 -0
- package/server/lib/data-dir-migration.js +40 -0
- package/server/lib/data-dir-migration.test.js +59 -0
- package/server/lib/dev-tunnel/DOCUMENTATION.md +13 -4
- package/server/lib/dev-tunnel/runtime.js +4 -4
- package/server/lib/dev-tunnel/tunnel.test.js +20 -0
- package/server/lib/event-stream/runtime.test.js +12 -12
- package/server/lib/fs/DOCUMENTATION.md +5 -0
- package/server/lib/fs/routes.js +63 -21
- package/server/lib/fs/routes.test.js +187 -1
- package/server/lib/git/DOCUMENTATION.md +32 -6
- package/server/lib/git/routes.js +40 -4
- package/server/lib/git/service.js +291 -215
- package/server/lib/git/service.test.js +593 -25
- package/server/lib/inherited-env.js +16 -9
- package/server/lib/inherited-env.test.js +8 -9
- package/server/lib/mcp-reconnect/DOCUMENTATION.md +55 -0
- package/server/lib/mcp-reconnect/runtime.js +122 -0
- package/server/lib/mcp-reconnect/runtime.test.js +140 -0
- package/server/lib/message-queue/DOCUMENTATION.md +161 -0
- package/server/lib/message-queue/runtime.js +870 -0
- package/server/lib/message-queue/runtime.test.js +482 -0
- package/server/lib/network-defaults.js +23 -0
- package/server/lib/network-defaults.test.js +52 -0
- package/server/lib/openchamber-sessions/routes.js +102 -0
- package/server/lib/openchamber-sessions/routes.test.js +88 -0
- package/server/lib/opencode/DOCUMENTATION.md +30 -6
- package/server/lib/opencode/bootstrap-runtime.js +2 -0
- package/server/lib/opencode/config-paths.test.js +116 -0
- package/server/lib/opencode/core-routes.js +1 -0
- package/server/lib/opencode/feature-routes-runtime.js +7 -0
- package/server/lib/opencode/input-history-scope.js +14 -0
- package/server/lib/opencode/managed-plugin-config.js +35 -0
- package/server/lib/opencode/openchamber-routes.js +64 -4
- package/server/lib/opencode/openchamber-routes.test.js +130 -3
- package/server/lib/opencode/plugins.js +2 -2
- package/server/lib/opencode/providers.js +44 -2
- package/server/lib/opencode/providers.test.js +131 -4
- package/server/lib/opencode/routes.js +9 -7
- package/server/lib/opencode/server-startup-runtime.js +1 -1
- package/server/lib/opencode/settings-files.js +214 -0
- package/server/lib/opencode/settings-helpers.js +113 -41
- package/server/lib/opencode/settings-helpers.test.js +342 -0
- package/server/lib/opencode/settings-registry.json +747 -0
- package/server/lib/opencode/settings-runtime.js +141 -20
- package/server/lib/opencode/settings-runtime.test.js +191 -4
- package/server/lib/opencode/shared.js +4 -1
- package/server/lib/opencode/shutdown-runtime.js +2 -0
- package/server/lib/opencode/skill-routes.js +2 -1
- package/server/lib/opencode/snippets.js +1 -2
- package/server/lib/package-manager.js +4 -29
- package/server/lib/package-manager.test.js +2 -2
- package/server/lib/project-context/DOCUMENTATION.md +25 -1
- package/server/lib/project-context/routes.http.test.js +18 -0
- package/server/lib/project-context/routes.js +15 -0
- package/server/lib/project-context/runtime.js +248 -23
- package/server/lib/project-context/runtime.test.js +106 -3
- package/server/lib/projects/DOCUMENTATION.md +76 -0
- package/server/lib/projects/project-config.js +142 -9
- package/server/lib/projects/project-id.js +17 -0
- package/server/lib/projects/project-setup.js +409 -0
- package/server/lib/projects/project-setup.test.js +557 -0
- package/server/lib/projects/routes.js +59 -0
- package/server/lib/quota/DOCUMENTATION.md +40 -2
- package/server/lib/quota/credentials/providers.js +4 -0
- package/server/lib/quota/credentials/store.js +1 -1
- package/server/lib/quota/credentials/store.test.js +4 -4
- package/server/lib/quota/index.js +1 -0
- package/server/lib/quota/providers/cline-pass.js +125 -0
- package/server/lib/quota/providers/cline-pass.test.js +103 -0
- package/server/lib/quota/providers/exe-dev.js +75 -0
- package/server/lib/quota/providers/exe-dev.test.js +47 -0
- package/server/lib/quota/providers/hyper.js +118 -0
- package/server/lib/quota/providers/hyper.test.js +126 -0
- package/server/lib/quota/providers/index.js +22 -0
- package/server/lib/quota/providers/neuralwatt.js +3 -6
- package/server/lib/quota/providers/neuralwatt.test.js +7 -7
- package/server/lib/quota/providers/ollama-cloud.js +31 -0
- package/server/lib/quota/providers/ollama-cloud.test.js +33 -1
- package/server/lib/quota/providers/opencode-go.js +2 -1
- package/server/lib/quota/providers/opencode-go.test.js +31 -2
- package/server/lib/quota/providers/openrouter.js +119 -24
- package/server/lib/quota/providers/openrouter.test.js +330 -0
- package/server/lib/quota/routes.js +2 -0
- package/server/lib/quota/utils/auth.js +1 -1
- package/server/lib/relay/DOCUMENTATION.md +56 -1
- package/server/lib/relay/cross-compat.test.js +26 -0
- package/server/lib/relay/downstream-scheduler.js +154 -0
- package/server/lib/relay/downstream-scheduler.test.js +162 -0
- package/server/lib/relay/e2ee.js +10 -8
- package/server/lib/relay/flow-control.test.js +177 -0
- package/server/lib/relay/host-client.js +37 -8
- package/server/lib/relay/host-client.test.js +9 -1
- package/server/lib/relay/tunnel-codec.js +9 -0
- package/server/lib/relay/tunnel-host.js +69 -18
- package/server/lib/relay/tunnel-host.test.js +9 -1
- package/server/lib/security/request-security.js +21 -6
- package/server/lib/security/request-security.test.js +42 -8
- package/server/lib/session-assist/runtime.js +7 -10
- package/server/lib/session-goal/DOCUMENTATION.md +20 -7
- package/server/lib/session-goal/create.js +3 -1
- package/server/lib/session-goal/runtime.js +81 -20
- package/server/lib/session-goal/runtime.test.js +426 -0
- package/server/lib/skills-catalog/DOCUMENTATION.md +1 -1
- package/server/lib/skills-catalog/install.js +3 -2
- package/server/lib/small-model/DOCUMENTATION.md +7 -2
- package/server/lib/small-model/call.js +17 -6
- package/server/lib/small-model/call.test.js +52 -0
- package/server/lib/small-model/index.js +7 -11
- package/server/lib/small-model/routes.js +2 -1
- package/server/lib/small-model/runtime-providers.js +15 -7
- package/server/lib/small-model/runtime-providers.test.js +7 -1
- package/server/lib/system-prompt/runtime.js +2 -21
- package/server/lib/terminal/DOCUMENTATION.md +14 -10
- package/server/lib/terminal/runtime.js +190 -31
- package/server/lib/terminal/runtime.test.js +571 -37
- package/server/lib/terminal/shells.js +17 -0
- package/server/lib/terminal/shells.test.js +32 -1
- package/server/lib/ui-auth/ui-auth.js +4 -1
- package/server/lib/ui-auth/ui-auth.test.js +16 -0
- package/server/lib/walkthrough/DOCUMENTATION.md +33 -14
- package/server/lib/walkthrough/index.js +1 -0
- package/server/lib/walkthrough/model-settings.js +8 -12
- package/server/lib/walkthrough/prompt.js +3 -1
- package/server/lib/walkthrough/routes.test.js +26 -5
- package/server/lib/walkthrough/sources.js +23 -2
- package/dist/assets/CommitSection-X-f2RgYr.js +0 -1
- package/dist/assets/DiffView-Dh1ey2x9.js +0 -4
- package/dist/assets/FilesView-3ZYP72ki.js +0 -17
- package/dist/assets/GitView-CMuN7z2Z.js +0 -6
- package/dist/assets/LinearIssuesView-CQiUDU8b.js +0 -1
- package/dist/assets/MarkdownImageGallery-bA0PS-tn.js +0 -1
- package/dist/assets/MarkdownRendererImpl-BGQi2PLq.js +0 -12
- package/dist/assets/OnboardingScreen-D3kYJIb6.js +0 -1
- package/dist/assets/PierreDiffViewer-Ct2A13NO.js +0 -157
- package/dist/assets/PlanView-6AUjzNQn.js +0 -5
- package/dist/assets/SettingsView-CHI8fFaK.js +0 -28
- package/dist/assets/ToolPartDiffPreview-DPAATNn6.js +0 -8
- package/dist/assets/WalkthroughView-AX-u2pFg.js +0 -4
- package/dist/assets/contextPanelEmbeddedChat-C9EcRSDF.js +0 -1
- package/dist/assets/de-DqVn5vme.js +0 -9
- package/dist/assets/es-hj43Z53i.js +0 -15
- package/dist/assets/fr-BmkZt2mF.js +0 -13
- package/dist/assets/index-L_ncV-6H.css +0 -1
- package/dist/assets/index-qES19dlJ.js +0 -7
- package/dist/assets/ja-DioMIEGF.js +0 -15
- package/dist/assets/ko-BqPgaQJb.js +0 -15
- package/dist/assets/main-CYgSu6dx.js +0 -928
- package/dist/assets/markdownCore-CWRCqSH2.js +0 -3
- package/dist/assets/patchFileDiff-BpMQZnYu.js +0 -10
- package/dist/assets/pl-slULBF4R.js +0 -15
- package/dist/assets/pt-BR-BqH8fuCH.js +0 -15
- package/dist/assets/renderElectronMiniChatApp-D77bmLqo.js +0 -2
- package/dist/assets/renderMobileApp-BQ3Kdm-d.js +0 -25
- package/dist/assets/runtimeEndpointReset-CRGn1gVj.js +0 -16
- package/dist/assets/scheduledTasksApi-BSRCtP6T.js +0 -1
- package/dist/assets/tr-CjEbw1Aw.js +0 -6
- package/dist/assets/uk-BkkfxbOs.js +0 -15
- package/dist/assets/useInlineCommentController-JsBboQwt.js +0 -2
- package/dist/assets/useRootScrollLock-C0N9mCwz.js +0 -1
- package/dist/assets/vendor-ghostty-web-CrxinGGH.js +0 -13
- package/dist/assets/vendor-legendapp-list-gg7YLRW7.js +0 -1
- package/dist/assets/vendor-zod-BLfBSgBh.js +0 -39
- package/dist/assets/zh-CN-C6FMdVgv.js +0 -15
- package/dist/assets/zh-TW-CdcMjDxv.js +0 -15
|
@@ -253,6 +253,40 @@ const latestCompletedAssistantMessageID = async ({ client, sessionID, directory
|
|
|
253
253
|
return asNonEmptyString(latest?.id);
|
|
254
254
|
};
|
|
255
255
|
|
|
256
|
+
/**
|
|
257
|
+
* Upper bound on one archive batch.
|
|
258
|
+
*
|
|
259
|
+
* The batch is applied one session at a time against OpenCode, so an unbounded
|
|
260
|
+
* list would hold a request open for as long as the list is large. Callers with
|
|
261
|
+
* more sessions than this send several batches and keep their own partial
|
|
262
|
+
* results.
|
|
263
|
+
*/
|
|
264
|
+
const MAX_ARCHIVE_BATCH = 500;
|
|
265
|
+
|
|
266
|
+
const parseArchiveRequest = (payload) => {
|
|
267
|
+
const rawIds = payload?.ids;
|
|
268
|
+
if (!Array.isArray(rawIds) || rawIds.length === 0) {
|
|
269
|
+
return { ok: false, error: 'ids must be a non-empty array of session ids' };
|
|
270
|
+
}
|
|
271
|
+
if (rawIds.length > MAX_ARCHIVE_BATCH) {
|
|
272
|
+
return { ok: false, error: `ids must contain at most ${MAX_ARCHIVE_BATCH} session ids` };
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const ids = [];
|
|
276
|
+
for (const value of rawIds) {
|
|
277
|
+
const id = asNonEmptyString(value);
|
|
278
|
+
if (!id) return { ok: false, error: 'ids must contain non-empty session ids' };
|
|
279
|
+
ids.push(id);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
const archivedAt = payload?.archivedAt;
|
|
283
|
+
if (archivedAt !== undefined && (!Number.isSafeInteger(archivedAt) || archivedAt <= 0)) {
|
|
284
|
+
return { ok: false, error: 'archivedAt must be a positive integer timestamp' };
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
return { ok: true, ids, archivedAt: archivedAt ?? Date.now() };
|
|
288
|
+
};
|
|
289
|
+
|
|
256
290
|
const resolveRequestedDirectory = async ({ payload, readSettingsFromDiskMigrated, sanitizeProjects, validateDirectoryPath }) => {
|
|
257
291
|
const projectID = asNonEmptyString(payload?.projectId) || asNonEmptyString(payload?.projectID);
|
|
258
292
|
if (projectID) {
|
|
@@ -579,6 +613,64 @@ export const createOpenChamberSessionService = (dependencies) => {
|
|
|
579
613
|
return { model, agent, variant, promptDispatched: true, dispatchedAsCommand: Boolean(resolvedCommand) };
|
|
580
614
|
};
|
|
581
615
|
|
|
616
|
+
/**
|
|
617
|
+
* Archive a batch of sessions in one request.
|
|
618
|
+
*
|
|
619
|
+
* The UI archives every session linked to a worktree before removing it.
|
|
620
|
+
* Doing that from the browser costs one request per session plus a store
|
|
621
|
+
* reconciliation between each of them, which is what made deleting a
|
|
622
|
+
* worktree with many sessions take tens of seconds. Here the batch stays on
|
|
623
|
+
* the server, next to OpenCode, and the client reconciles once.
|
|
624
|
+
*
|
|
625
|
+
* Sessions are updated one at a time on purpose: they are archived against a
|
|
626
|
+
* single OpenCode instance, and a fan-out of concurrent writes would trade a
|
|
627
|
+
* UI stall for server event-loop starvation. One failed session never stops
|
|
628
|
+
* the batch — it is reported in `failedIds` while the rest still archive, so
|
|
629
|
+
* callers keep the partial-failure behaviour they already show.
|
|
630
|
+
*/
|
|
631
|
+
const archive = async (payload = {}) => {
|
|
632
|
+
const parsed = parseArchiveRequest(payload);
|
|
633
|
+
if (!parsed.ok) {
|
|
634
|
+
throw new OpenChamberControlError(parsed.error, 400);
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
const resolvedDirectory = await resolveRequestedDirectory({
|
|
638
|
+
payload,
|
|
639
|
+
readSettingsFromDiskMigrated,
|
|
640
|
+
sanitizeProjects,
|
|
641
|
+
validateDirectoryPath,
|
|
642
|
+
});
|
|
643
|
+
if (!resolvedDirectory.ok) {
|
|
644
|
+
throw new OpenChamberControlError(resolvedDirectory.error, resolvedDirectory.status || 400);
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
if (typeof waitForOpenCodeReady === 'function') await waitForOpenCodeReady(10_000, 250);
|
|
648
|
+
|
|
649
|
+
const directory = resolvedDirectory.directory;
|
|
650
|
+
const baseUrl = buildOpenCodeUrl('/', '').replace(/\/$/, '');
|
|
651
|
+
const client = createOpencodeClient({ baseUrl, headers: getOpenCodeAuthHeaders() });
|
|
652
|
+
|
|
653
|
+
const archived = [];
|
|
654
|
+
const failedIds = [];
|
|
655
|
+
for (const sessionID of parsed.ids) {
|
|
656
|
+
try {
|
|
657
|
+
const response = await client.session.update({
|
|
658
|
+
sessionID,
|
|
659
|
+
directory,
|
|
660
|
+
time: { archived: parsed.archivedAt },
|
|
661
|
+
});
|
|
662
|
+
const session = response?.data;
|
|
663
|
+
if (session?.id) archived.push(session);
|
|
664
|
+
else failedIds.push(sessionID);
|
|
665
|
+
} catch (error) {
|
|
666
|
+
console.warn('[OpenChamberSessions] failed to archive session', sessionID, error);
|
|
667
|
+
failedIds.push(sessionID);
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
return { directory, archived, failedIds };
|
|
672
|
+
};
|
|
673
|
+
|
|
582
674
|
const create = async (payload = {}) => {
|
|
583
675
|
const title = asNonEmptyString(payload.title);
|
|
584
676
|
const prompt = asNonEmptyString(payload.prompt);
|
|
@@ -813,6 +905,7 @@ export const createOpenChamberSessionService = (dependencies) => {
|
|
|
813
905
|
|
|
814
906
|
return {
|
|
815
907
|
create,
|
|
908
|
+
archive,
|
|
816
909
|
send: (sessionID, payload) => runExisting('send', sessionID, payload),
|
|
817
910
|
fork: (sessionID, payload) => runExisting('fork', sessionID, payload),
|
|
818
911
|
};
|
|
@@ -843,6 +936,15 @@ export const registerOpenChamberSessionRoutes = (app, dependencies) => {
|
|
|
843
936
|
}
|
|
844
937
|
});
|
|
845
938
|
|
|
939
|
+
app.post('/api/openchamber/sessions/archive', express.json({ limit: '1mb' }), async (req, res) => {
|
|
940
|
+
try {
|
|
941
|
+
return res.json(await service.archive(req.body && typeof req.body === 'object' ? req.body : {}));
|
|
942
|
+
} catch (error) {
|
|
943
|
+
console.error('[OpenChamberSessions] failed to archive sessions:', error);
|
|
944
|
+
return sendServiceError(res, error, 'Failed to archive sessions');
|
|
945
|
+
}
|
|
946
|
+
});
|
|
947
|
+
|
|
846
948
|
app.post(
|
|
847
949
|
'/api/openchamber/sessions/:sessionId/send',
|
|
848
950
|
express.json({ limit: '1mb' }),
|
|
@@ -17,6 +17,7 @@ const getWorktreeBootstrapStatusMock = vi.fn(async () => ({
|
|
|
17
17
|
const sessionCreateMock = vi.fn(async () => ({ data: { id: 'ses_123' } }));
|
|
18
18
|
const sessionForkMock = vi.fn(async () => ({ data: { id: 'ses_fork', title: 'Forked session' } }));
|
|
19
19
|
const sessionMessagesMock = vi.fn(async () => ({ data: [] }));
|
|
20
|
+
const sessionUpdateMock = vi.fn(async ({ sessionID }) => ({ data: { id: sessionID, time: { archived: 1 } } }));
|
|
20
21
|
|
|
21
22
|
let existingSessionMessages = [];
|
|
22
23
|
let dispatchedUserMessageSeq = 0;
|
|
@@ -78,6 +79,7 @@ vi.mock('@opencode-ai/sdk/v2', () => ({
|
|
|
78
79
|
fork: sessionForkMock,
|
|
79
80
|
messages: sessionMessagesMock,
|
|
80
81
|
command: sessionCommandMock,
|
|
82
|
+
update: sessionUpdateMock,
|
|
81
83
|
},
|
|
82
84
|
command: {
|
|
83
85
|
list: commandListMock,
|
|
@@ -132,6 +134,92 @@ describe('openchamber session routes', () => {
|
|
|
132
134
|
sessionCommandMock.mockResolvedValue({ data: {} });
|
|
133
135
|
commandListMock.mockReset();
|
|
134
136
|
commandListMock.mockResolvedValue({ data: [] });
|
|
137
|
+
sessionUpdateMock.mockReset();
|
|
138
|
+
sessionUpdateMock.mockImplementation(async ({ sessionID }) => ({ data: { id: sessionID, time: { archived: 1 } } }));
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
describe('archiving a batch of sessions', () => {
|
|
142
|
+
it('archives every id against the resolved directory and returns the archived sessions', async () => {
|
|
143
|
+
const { app } = createApp();
|
|
144
|
+
const response = await request(app)
|
|
145
|
+
.post('/api/openchamber/sessions/archive')
|
|
146
|
+
.send({ directory: '/repo/app', ids: ['ses_a', 'ses_b'], archivedAt: 1700 })
|
|
147
|
+
.expect(200);
|
|
148
|
+
|
|
149
|
+
expect(response.body.directory).toBe('/repo/app');
|
|
150
|
+
expect(response.body.archived.map((session) => session.id)).toEqual(['ses_a', 'ses_b']);
|
|
151
|
+
expect(response.body.failedIds).toEqual([]);
|
|
152
|
+
expect(sessionUpdateMock).toHaveBeenCalledTimes(2);
|
|
153
|
+
expect(sessionUpdateMock).toHaveBeenCalledWith({
|
|
154
|
+
sessionID: 'ses_a',
|
|
155
|
+
directory: '/repo/app',
|
|
156
|
+
time: { archived: 1700 },
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it('keeps archiving after a failed session and reports it as failed', async () => {
|
|
161
|
+
sessionUpdateMock.mockImplementation(async ({ sessionID }) => {
|
|
162
|
+
if (sessionID === 'ses_b') throw new Error('session.update failed');
|
|
163
|
+
return { data: { id: sessionID } };
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
const { app } = createApp();
|
|
167
|
+
const response = await request(app)
|
|
168
|
+
.post('/api/openchamber/sessions/archive')
|
|
169
|
+
.send({ directory: '/repo/app', ids: ['ses_a', 'ses_b', 'ses_c'] })
|
|
170
|
+
.expect(200);
|
|
171
|
+
|
|
172
|
+
expect(response.body.archived.map((session) => session.id)).toEqual(['ses_a', 'ses_c']);
|
|
173
|
+
expect(response.body.failedIds).toEqual(['ses_b']);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it('reports a session the server did not confirm as failed instead of archived', async () => {
|
|
177
|
+
sessionUpdateMock.mockImplementation(async ({ sessionID }) => (
|
|
178
|
+
sessionID === 'ses_b' ? { data: null } : { data: { id: sessionID } }
|
|
179
|
+
));
|
|
180
|
+
|
|
181
|
+
const { app } = createApp();
|
|
182
|
+
const response = await request(app)
|
|
183
|
+
.post('/api/openchamber/sessions/archive')
|
|
184
|
+
.send({ directory: '/repo/app', ids: ['ses_a', 'ses_b'] })
|
|
185
|
+
.expect(200);
|
|
186
|
+
|
|
187
|
+
expect(response.body.archived.map((session) => session.id)).toEqual(['ses_a']);
|
|
188
|
+
expect(response.body.failedIds).toEqual(['ses_b']);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it('rejects an empty batch, an oversized batch, and non-string ids', async () => {
|
|
192
|
+
const { app } = createApp();
|
|
193
|
+
|
|
194
|
+
await request(app).post('/api/openchamber/sessions/archive').send({ directory: '/repo/app', ids: [] }).expect(400);
|
|
195
|
+
await request(app)
|
|
196
|
+
.post('/api/openchamber/sessions/archive')
|
|
197
|
+
.send({ directory: '/repo/app', ids: Array.from({ length: 501 }, (_, index) => `ses_${index}`) })
|
|
198
|
+
.expect(400);
|
|
199
|
+
await request(app)
|
|
200
|
+
.post('/api/openchamber/sessions/archive')
|
|
201
|
+
.send({ directory: '/repo/app', ids: ['ses_a', ''] })
|
|
202
|
+
.expect(400);
|
|
203
|
+
await request(app)
|
|
204
|
+
.post('/api/openchamber/sessions/archive')
|
|
205
|
+
.send({ directory: '/repo/app', ids: ['ses_a'], archivedAt: -1 })
|
|
206
|
+
.expect(400);
|
|
207
|
+
|
|
208
|
+
expect(sessionUpdateMock).not.toHaveBeenCalled();
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it('rejects a directory the runtime does not accept', async () => {
|
|
212
|
+
const { app } = createApp({
|
|
213
|
+
validateDirectoryPath: async () => ({ ok: false, error: 'Invalid directory' }),
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
await request(app)
|
|
217
|
+
.post('/api/openchamber/sessions/archive')
|
|
218
|
+
.send({ directory: '/elsewhere', ids: ['ses_a'] })
|
|
219
|
+
.expect(400);
|
|
220
|
+
|
|
221
|
+
expect(sessionUpdateMock).not.toHaveBeenCalled();
|
|
222
|
+
});
|
|
135
223
|
});
|
|
136
224
|
|
|
137
225
|
it('creates a session for a directory', async () => {
|
|
@@ -34,6 +34,8 @@ This module provides OpenCode server integration utilities for the web server ru
|
|
|
34
34
|
- `packages/web/server/lib/opencode/startup-performance.js`: opt-in startup phase diagnostics with fixed labels and numeric metadata allowlists.
|
|
35
35
|
- `packages/web/server/lib/agent-tool/runtime.js`: managed OpenCode custom-tool materialization, environment injection, loopback authentication, and fixed CLI action dispatch.
|
|
36
36
|
- `packages/web/server/lib/system-prompt/runtime.js`: opt-in managed OpenCode system-prompt optimizer materialization and plugin injection.
|
|
37
|
+
- `packages/web/server/lib/mcp-reconnect/runtime.js`: always-on managed OpenCode plugin that reconnects MCP servers OpenCode marked `failed`, with per-server backoff.
|
|
38
|
+
- `packages/web/server/lib/opencode/managed-plugin-config.js`: the one `OPENCODE_CONFIG_CONTENT` merge every managed plugin (agent tools, system prompt optimizer, MCP reconnect) appends itself through.
|
|
37
39
|
- `packages/web/server/lib/opencode/server-utils-runtime.js`: shared server runtime utilities for OpenCode proxy wiring, OpenCode port/readiness helpers, and snapshot fetchers.
|
|
38
40
|
- `packages/web/server/lib/opencode/openchamber-routes.js`: OpenChamber update and models metadata route registration.
|
|
39
41
|
- `packages/web/server/lib/opencode/pwa-manifest-routes.js`: PWA manifest route registration with recent-session shortcut resolution and short-lived caching.
|
|
@@ -61,12 +63,12 @@ This module provides OpenCode server integration utilities for the web server ru
|
|
|
61
63
|
|
|
62
64
|
## Public exports (providers.js)
|
|
63
65
|
- `getProviderSources(providerId, workingDirectory)`: Resolves which OpenCode config layers define a provider.
|
|
64
|
-
- `upsertProviderConfig(providerId, config, workingDirectory, scope?, options?)`: Validates and writes a custom
|
|
65
|
-
- `validateCustomProviderConfig(providerId, config, options?)`: Structural validation for custom provider payloads (id format, http(s) base URL, models, credentials via `env` or `hasStoredAuth`).
|
|
66
|
+
- `upsertProviderConfig(providerId, config, workingDirectory, scope?, options?)`: Validates and writes a custom provider block (`npm`, `name`, `options.baseURL`, `models`, optional `env`/`headers`) into the user/project/custom config layer. The adapter may be OpenAI Chat Completions, OpenAI Responses, or Anthropic Messages. Existing provider, option, and retained-model fields not managed by the form are preserved; omitted models, headers, and env credentials remain explicit removals. Updating a legacy `providers` entry migrates it to the canonical `provider` key. Does not store API keys. Requires `config.env` or `options.hasStoredAuth` (auth already written via OpenCode `auth.set`). Edit flows must pass the provider's effective existing layer (`custom` > `project` > `user`) so updates do not create a global user override.
|
|
67
|
+
- `validateCustomProviderConfig(providerId, config, options?)`: Structural validation for custom provider payloads (id format, adapter allowlist `@ai-sdk/openai-compatible`/`@ai-sdk/openai`/`@ai-sdk/anthropic`, http(s) base URL, models, credentials via `env` or `hasStoredAuth`).
|
|
66
68
|
- `removeProviderConfig(providerId, workingDirectory, scope?)`: Removes a provider block from the selected config layer.
|
|
67
69
|
|
|
68
70
|
## Public exports (shared.js)
|
|
69
|
-
- `OPENCODE_CONFIG_DIR`, `AGENT_DIR`, `COMMAND_DIR`, `SKILL_DIR`, `CONFIG_FILE`: Path constants. `OPENCODE_CONFIG` is resolved at call time for the custom config layer
|
|
71
|
+
- `OPENCODE_CONFIG_DIR`, `AGENT_DIR`, `COMMAND_DIR`, `SKILL_DIR`, `CONFIG_FILE`: Path constants rooted at `$XDG_CONFIG_HOME/opencode` when `XDG_CONFIG_HOME` is non-empty, otherwise `~/.config/opencode`. These constants are evaluated when the module loads; no files are migrated. `OPENCODE_CONFIG` remains a separate explicit config-file path and is resolved at call time for the custom config layer; it does not replace the global config directory.
|
|
70
72
|
- `AGENT_SCOPE`, `COMMAND_SCOPE`, `SKILL_SCOPE`: Scope constants with USER and PROJECT values.
|
|
71
73
|
- `ensureDirs()`: Creates required OpenCode directories.
|
|
72
74
|
- `parseMdFile(filePath)`, `writeMdFile(filePath, frontmatter, body)`: Markdown file operations with YAML frontmatter.
|
|
@@ -130,7 +132,8 @@ The runtime maintains active-session count incrementally from idempotent activit
|
|
|
130
132
|
- `killProcessOnPort(port)`
|
|
131
133
|
|
|
132
134
|
Managed OpenCode launch also merges the environment returned by the agent-tool
|
|
133
|
-
runtime
|
|
135
|
+
runtime, the opt-in system prompt optimizer, and the always-on MCP reconnect
|
|
136
|
+
plugin, each appending its `file://` entry to the previous one's config. PATH and `OPENCODE_SERVER_PASSWORD` remain lifecycle-owned and cannot
|
|
134
137
|
be replaced by injected values. External OpenCode processes receive no
|
|
135
138
|
OpenChamber tool injection. Managed launch env strips AppImage `ARGV0` before
|
|
136
139
|
spawn so zsh-backed OpenCode tools do not rewrite child argv[0] to the AppImage
|
|
@@ -208,7 +211,13 @@ Managed health failures are classified as `timeout`, `connection_refused`, `conn
|
|
|
208
211
|
- `writeSettingsToDisk(settings)`
|
|
209
212
|
- `persistSettings(changes)`
|
|
210
213
|
- Persistent permission auto-accept policy is stored under `permissionAutoAccept`; execution ownership lives in `lib/permission-auto-accept/`.
|
|
211
|
-
-
|
|
214
|
+
- Queued follow-up messages live in `<data-dir>/message-queue.json`, not in settings; execution ownership lives in `lib/message-queue/`.
|
|
215
|
+
- Shared sidebar preferences are stored as validated top-level fields: `sidebarProjectDisplayMode`, `sidebarSessionGroupingMode`, `sidebarProjectSortOrder`, and `sidebarShowRecentSection`. Device-local picker selection and sticky-header state do not enter either settings file.
|
|
216
|
+
- Two files (`settings-files.js`): `settings.json` holds instance facts and any legacy or unknown keys; `preferences.json` beside it holds every key the generated registry snapshot (`settings-registry.json`) marks `profile`, as `{ version: 1, fields: { key: { value, updatedAt, surfaces? } } }`. Keys the snapshot marks `perSurface` are stored per surface kind: `GET`/`PUT /api/config/settings` read the client's kind from the `surface` query parameter (`settingsSurfaceOf`; the legacy `x-openchamber-surface` header is still honoured, but a header forces a CORS preflight that cross-origin shells and older instances refuse, so clients must not send one) (`web`, `desktop`, `vscode`, `mobile`; anything else means base), `persistSettings(changes, { surface })` writes a changed per-surface key under `surfaces[surface]` and never touches its base, and `readSettingsFromDisk({ surface })` resolves that kind's value first, the base otherwise. Callers without a surface (migrations, the seed, server-side feature writers) read and write the base. `readSettingsFromDisk()` returns the merged document and seeds `preferences.json` once from an existing `settings.json` (which it leaves intact). An existing `preferences.json` that fails to parse is a failure, not an empty profile: it is never seeded or overwritten, the merged read serves the instance part, and `persistSettings` drops profile keys with a warning until the file is fixed or removed. `writeSettingsToDisk(document)` splits by scope and writes `settings.json` as the instance part plus a copy of the profile's base values (`legacySettingsDocumentOf`): a build from before the split reads only that file, so a rollback keeps the user's preferences, while current builds ignore the copy because `preferences.json` wins in the merge; device keys are dropped from writes. Modules that read one profile key off the disk on a hot path use `readMergedSettingsSync`.
|
|
217
|
+
|
|
218
|
+
## Public exports (settings-files.js)
|
|
219
|
+
- `parsePreferencesDocument(raw)`, `serializePreferencesDocument(fields)`, `flattenPreferences(fields)`, `buildPreferencesFields(previousFields, document, now)`, `instancePartOf(document)`, `seedPreferencesFrom(document, now)`, `readMergedSettingsSync({ fs, path, settingsFilePath })`, `getSettingsScope(key)`, `isProfileSettingsKey(key)`, `isDeviceSettingsKey(key)`, `preferencesFilePathFor(settingsFilePath, path)`.
|
|
220
|
+
- The VS Code extension host writes the same two files with the same shape (`packages/vscode/src/settings-files.ts`); format changes go to both.
|
|
212
221
|
|
|
213
222
|
## Public exports (settings-helpers.js)
|
|
214
223
|
- `createSettingsHelpers(dependencies)`: creates settings helper runtime for settings request/response shaping.
|
|
@@ -359,9 +368,20 @@ before starting managed OpenCode. The managed custom tool therefore receives
|
|
|
359
368
|
an authoritative loopback callback URL even when OpenChamber binds port `0`.
|
|
360
369
|
|
|
361
370
|
## Public exports (openchamber-routes.js)
|
|
371
|
+
Browser completion checks use `appType=web&updateStatus=true` to stay on the
|
|
372
|
+
Desktop Host's native updater. A rejected native restart is retained in the
|
|
373
|
+
server process and returned to these polls as `DESKTOP_UPDATE_RESTART_FAILED`;
|
|
374
|
+
ordinary availability checks remain usable so a browser reload can offer a
|
|
375
|
+
retry. Starting another installation clears the previous restart error.
|
|
376
|
+
The shared UI's `lib/web-update.ts` parses install/check responses and waits
|
|
377
|
+
for the installed native target version, rather than treating absence of a
|
|
378
|
+
newer release as installation success. Poll requests have individual deadlines
|
|
379
|
+
within a ten-minute overall deadline.
|
|
380
|
+
|
|
362
381
|
- `registerOpenChamberRoutes(app, dependencies)`: registers OpenChamber endpoints:
|
|
363
382
|
- `GET /api/openchamber/update-check`
|
|
364
383
|
- `POST /api/openchamber/update-install`
|
|
384
|
+
- Desktop-managed hosts delegate authenticated Web update requests to the Electron main process, which checks, downloads, and applies the update through `electron-updater` before restarting the host.
|
|
365
385
|
- Foreground servers running under a systemd user unit queue installation in
|
|
366
386
|
a separate transient unit and restart the configured service afterwards.
|
|
367
387
|
`OPENCHAMBER_SYSTEMD_UNIT` overrides the default `openchamber.service`.
|
|
@@ -417,7 +437,7 @@ an authoritative loopback callback URL even when OpenChamber binds port `0`.
|
|
|
417
437
|
|
|
418
438
|
## Storage and configuration
|
|
419
439
|
- Provider auth: `~/.local/share/opencode/auth.json`.
|
|
420
|
-
- User config: `~/.config/opencode/opencode.json
|
|
440
|
+
- User config: `$XDG_CONFIG_HOME/opencode/opencode.json`, falling back to `~/.config/opencode/opencode.json` when unset or blank.
|
|
421
441
|
- Project config: `<workingDirectory>/.opencode/opencode.json` or `opencode.json`.
|
|
422
442
|
- Custom config: `OPENCODE_CONFIG` env var path.
|
|
423
443
|
- Rate limit config: `OPENCHAMBER_RATE_LIMIT_MAX_ATTEMPTS`, `OPENCHAMBER_RATE_LIMIT_NO_IP_MAX_ATTEMPTS` env vars.
|
|
@@ -429,3 +449,7 @@ an authoritative loopback callback URL even when OpenChamber binds port `0`.
|
|
|
429
449
|
- Config merging follows priority: custom > project > user.
|
|
430
450
|
- UI auth uses scrypt for password hashing with constant-time comparison.
|
|
431
451
|
- Tunnel auth treats `host.docker.internal` as local-only when the socket remote IP is private/loopback.
|
|
452
|
+
|
|
453
|
+
The behavior `GET /api/behavior/agents-md` response includes `path`, the effective
|
|
454
|
+
server-side filename, whether or not the file exists. Settings displays this
|
|
455
|
+
path without deriving a directory from the browser environment.
|
|
@@ -63,6 +63,7 @@ export const createBootstrapRuntime = (dependencies) => {
|
|
|
63
63
|
getCachedZenModels,
|
|
64
64
|
setAutoAcceptSession,
|
|
65
65
|
agentToolRuntime,
|
|
66
|
+
desktopUpdater,
|
|
66
67
|
} = options;
|
|
67
68
|
|
|
68
69
|
const uiAuthController = createUiAuth({
|
|
@@ -153,6 +154,7 @@ export const createBootstrapRuntime = (dependencies) => {
|
|
|
153
154
|
readSettingsFromDiskMigrated,
|
|
154
155
|
fetchFreeZenModels,
|
|
155
156
|
getCachedZenModels,
|
|
157
|
+
desktopUpdater,
|
|
156
158
|
});
|
|
157
159
|
|
|
158
160
|
return {
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import os from 'os';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
5
|
+
|
|
6
|
+
const originalXdgConfigHome = process.env.XDG_CONFIG_HOME;
|
|
7
|
+
const originalOpenCodeConfig = process.env.OPENCODE_CONFIG;
|
|
8
|
+
|
|
9
|
+
afterEach(() => {
|
|
10
|
+
if (originalXdgConfigHome === undefined) delete process.env.XDG_CONFIG_HOME;
|
|
11
|
+
else process.env.XDG_CONFIG_HOME = originalXdgConfigHome;
|
|
12
|
+
if (originalOpenCodeConfig === undefined) delete process.env.OPENCODE_CONFIG;
|
|
13
|
+
else process.env.OPENCODE_CONFIG = originalOpenCodeConfig;
|
|
14
|
+
vi.resetModules();
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
async function loadOpenCodeModules() {
|
|
18
|
+
vi.resetModules();
|
|
19
|
+
return Promise.all([
|
|
20
|
+
import('./shared.js'),
|
|
21
|
+
import('./agents.js'),
|
|
22
|
+
import('./commands.js'),
|
|
23
|
+
import('./skills.js'),
|
|
24
|
+
import('./snippets.js'),
|
|
25
|
+
import('./plugins.js'),
|
|
26
|
+
import('./routes.js'),
|
|
27
|
+
]);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
describe('OpenCode global config paths', () => {
|
|
31
|
+
it('derives all shared constants from a non-empty XDG_CONFIG_HOME', async () => {
|
|
32
|
+
const xdgConfigHome = fs.mkdtempSync(path.join(os.tmpdir(), 'openchamber-xdg-'));
|
|
33
|
+
process.env.XDG_CONFIG_HOME = xdgConfigHome;
|
|
34
|
+
|
|
35
|
+
const [{ OPENCODE_CONFIG_DIR, AGENT_DIR, COMMAND_DIR, SKILL_DIR, CONFIG_FILE }] = await loadOpenCodeModules();
|
|
36
|
+
const configDir = path.join(xdgConfigHome, 'opencode');
|
|
37
|
+
expect(OPENCODE_CONFIG_DIR).toBe(configDir);
|
|
38
|
+
expect(AGENT_DIR).toBe(path.join(configDir, 'agents'));
|
|
39
|
+
expect(COMMAND_DIR).toBe(path.join(configDir, 'commands'));
|
|
40
|
+
expect(SKILL_DIR).toBe(path.join(configDir, 'skills'));
|
|
41
|
+
expect(CONFIG_FILE).toBe(path.join(configDir, 'config.json'));
|
|
42
|
+
|
|
43
|
+
fs.rmSync(xdgConfigHome, { recursive: true, force: true });
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it.each([undefined, ' '])('falls back to ~/.config/opencode when XDG_CONFIG_HOME is %s', async (xdgConfigHome) => {
|
|
47
|
+
if (xdgConfigHome === undefined) delete process.env.XDG_CONFIG_HOME;
|
|
48
|
+
else process.env.XDG_CONFIG_HOME = xdgConfigHome;
|
|
49
|
+
|
|
50
|
+
const [{ OPENCODE_CONFIG_DIR }] = await loadOpenCodeModules();
|
|
51
|
+
expect(OPENCODE_CONFIG_DIR).toBe(path.join(os.homedir(), '.config', 'opencode'));
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('keeps global CRUD below XDG_CONFIG_HOME while project files stay in the project', async () => {
|
|
55
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'openchamber-config-paths-'));
|
|
56
|
+
const xdgConfigHome = path.join(root, 'xdg');
|
|
57
|
+
const projectDir = path.join(root, 'project');
|
|
58
|
+
process.env.XDG_CONFIG_HOME = xdgConfigHome;
|
|
59
|
+
delete process.env.OPENCODE_CONFIG;
|
|
60
|
+
|
|
61
|
+
const [, agents, commands, skills, snippets, plugins] = await loadOpenCodeModules();
|
|
62
|
+
agents.createAgent('global-agent', { description: 'Global', prompt: 'Global prompt' }, projectDir, 'user');
|
|
63
|
+
commands.createCommand('global-command', { description: 'Global', template: 'Global template' }, projectDir, 'user');
|
|
64
|
+
skills.createSkill('global-skill', { description: 'Global', instructions: 'Global instructions' }, projectDir, 'user');
|
|
65
|
+
snippets.createSnippet('global-snippet', { content: 'Global snippet' }, projectDir, 'global');
|
|
66
|
+
plugins.createPluginEntry({ spec: 'global-plugin', scope: 'user' }, projectDir);
|
|
67
|
+
|
|
68
|
+
const configDir = path.join(xdgConfigHome, 'opencode');
|
|
69
|
+
expect(fs.existsSync(path.join(configDir, 'agents', 'global-agent.md'))).toBe(true);
|
|
70
|
+
expect(fs.existsSync(path.join(configDir, 'commands', 'global-command.md'))).toBe(true);
|
|
71
|
+
expect(fs.existsSync(path.join(configDir, 'skills', 'global-skill', 'SKILL.md'))).toBe(true);
|
|
72
|
+
expect(fs.existsSync(path.join(configDir, 'snippet', 'global-snippet.md'))).toBe(true);
|
|
73
|
+
expect(JSON.parse(fs.readFileSync(path.join(configDir, 'config.json'), 'utf8')).plugin).toEqual(['global-plugin']);
|
|
74
|
+
|
|
75
|
+
agents.createAgent('project-agent', { description: 'Project', prompt: 'Project prompt' }, projectDir, 'project');
|
|
76
|
+
commands.createCommand('project-command', { description: 'Project', template: 'Project template' }, projectDir, 'project');
|
|
77
|
+
skills.createSkill('project-skill', { description: 'Project', instructions: 'Project instructions' }, projectDir, 'project');
|
|
78
|
+
|
|
79
|
+
expect(fs.existsSync(path.join(projectDir, '.opencode', 'agents', 'project-agent.md'))).toBe(true);
|
|
80
|
+
expect(fs.existsSync(path.join(projectDir, '.opencode', 'commands', 'project-command.md'))).toBe(true);
|
|
81
|
+
expect(fs.existsSync(path.join(projectDir, '.opencode', 'skills', 'project-skill', 'SKILL.md'))).toBe(true);
|
|
82
|
+
|
|
83
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('writes global AGENTS.md below XDG_CONFIG_HOME', async () => {
|
|
87
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'openchamber-agents-md-'));
|
|
88
|
+
process.env.XDG_CONFIG_HOME = path.join(root, 'xdg');
|
|
89
|
+
delete process.env.OPENCODE_CONFIG;
|
|
90
|
+
|
|
91
|
+
const [, , , , , , routes] = await loadOpenCodeModules();
|
|
92
|
+
const handlers = new Map();
|
|
93
|
+
const app = {
|
|
94
|
+
get(route, ...callbacks) { handlers.set(`GET ${route}`, callbacks.at(-1)); },
|
|
95
|
+
put(route, ...callbacks) { handlers.set(`PUT ${route}`, callbacks.at(-1)); },
|
|
96
|
+
post() {},
|
|
97
|
+
delete() {},
|
|
98
|
+
};
|
|
99
|
+
routes.registerOpenCodeRoutes(app, {});
|
|
100
|
+
|
|
101
|
+
const response = { json: vi.fn(), status: vi.fn(() => response) };
|
|
102
|
+
await handlers.get('GET /api/behavior/agents-md')({}, response);
|
|
103
|
+
expect(response.json).toHaveBeenLastCalledWith({
|
|
104
|
+
content: '', exists: false, path: path.join(process.env.XDG_CONFIG_HOME, 'opencode', 'AGENTS.md'),
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
await handlers.get('PUT /api/behavior/agents-md')({ body: { content: 'Global behavior' } }, response);
|
|
108
|
+
|
|
109
|
+
expect(fs.readFileSync(path.join(process.env.XDG_CONFIG_HOME, 'opencode', 'AGENTS.md'), 'utf8')).toBe('Global behavior');
|
|
110
|
+
await handlers.get('GET /api/behavior/agents-md')({}, response);
|
|
111
|
+
expect(response.json).toHaveBeenLastCalledWith({
|
|
112
|
+
content: 'Global behavior', exists: true, path: path.join(process.env.XDG_CONFIG_HOME, 'opencode', 'AGENTS.md'),
|
|
113
|
+
});
|
|
114
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
115
|
+
});
|
|
116
|
+
});
|
|
@@ -1065,6 +1065,7 @@ export const registerCommonRequestMiddleware = (app, dependencies) => {
|
|
|
1065
1065
|
req.path.startsWith('/api/push') ||
|
|
1066
1066
|
req.path.startsWith('/api/notifications') ||
|
|
1067
1067
|
req.path.startsWith('/api/permission-auto-accept') ||
|
|
1068
|
+
req.path.startsWith('/api/message-queue') ||
|
|
1068
1069
|
req.path.startsWith('/api/provider') ||
|
|
1069
1070
|
req.path.startsWith('/api/session-folders') ||
|
|
1070
1071
|
req.path.startsWith('/api/small-model') ||
|
|
@@ -10,9 +10,11 @@ import { registerDevServerRoutes } from '../dev-servers/routes.js';
|
|
|
10
10
|
import { registerMagicPromptRoutes } from '../magic-prompts/routes.js';
|
|
11
11
|
import { registerSessionFoldersRoutes } from '../session-folders/routes.js';
|
|
12
12
|
import { registerProjectContextRoutes } from '../project-context/routes.js';
|
|
13
|
+
import { registerProjectSetupRoutes } from '../projects/routes.js';
|
|
13
14
|
import { registerAgentMemoryRoutes } from '../agent-memory/routes.js';
|
|
14
15
|
import { registerSessionKnowledgeRoutes } from '../session-knowledge/routes.js';
|
|
15
16
|
import { registerPermissionAutoAcceptRoutes } from '../permission-auto-accept/runtime.js';
|
|
17
|
+
import { registerMessageQueueRoutes } from '../message-queue/runtime.js';
|
|
16
18
|
import { registerConfigEntityRoutes } from './config-entity-routes.js';
|
|
17
19
|
import { registerSettingsUtilityRoutes } from './core-routes.js';
|
|
18
20
|
import { registerProjectIconRoutes } from './project-icon-routes.js';
|
|
@@ -97,6 +99,7 @@ export const createFeatureRoutesRuntime = (dependencies) => {
|
|
|
97
99
|
createFsSearchRuntime,
|
|
98
100
|
openchamberDataDir,
|
|
99
101
|
openchamberUserConfigRoot,
|
|
102
|
+
managedChatsRoot,
|
|
100
103
|
normalizeDirectoryPath,
|
|
101
104
|
resolveProjectDirectory,
|
|
102
105
|
resolveOptionalProjectDirectory,
|
|
@@ -132,6 +135,7 @@ export const createFeatureRoutesRuntime = (dependencies) => {
|
|
|
132
135
|
writeSseEvent,
|
|
133
136
|
emitSessionCreatedEvent,
|
|
134
137
|
permissionAutoAcceptRuntime,
|
|
138
|
+
messageQueueRuntime,
|
|
135
139
|
} = routeDependencies;
|
|
136
140
|
|
|
137
141
|
registerSettingsUtilityRoutes(app, {
|
|
@@ -141,6 +145,7 @@ export const createFeatureRoutesRuntime = (dependencies) => {
|
|
|
141
145
|
});
|
|
142
146
|
|
|
143
147
|
registerPermissionAutoAcceptRoutes(app, permissionAutoAcceptRuntime);
|
|
148
|
+
registerMessageQueueRoutes(app, messageQueueRuntime);
|
|
144
149
|
|
|
145
150
|
registerOpenCodeRoutes(app, {
|
|
146
151
|
crypto,
|
|
@@ -310,6 +315,7 @@ export const createFeatureRoutesRuntime = (dependencies) => {
|
|
|
310
315
|
openchamberDataDir,
|
|
311
316
|
});
|
|
312
317
|
registerProjectContextRoutes(app, { projectContextRuntime });
|
|
318
|
+
registerProjectSetupRoutes(app, { projectConfigRuntime });
|
|
313
319
|
registerAgentMemoryRoutes(app, { agentMemoryRuntime, isAgentMemoryEnabled });
|
|
314
320
|
registerSessionKnowledgeRoutes(app, { sessionKnowledgeRuntime });
|
|
315
321
|
|
|
@@ -329,6 +335,7 @@ export const createFeatureRoutesRuntime = (dependencies) => {
|
|
|
329
335
|
buildAugmentedPath,
|
|
330
336
|
resolveGitBinaryForSpawn,
|
|
331
337
|
openchamberUserConfigRoot,
|
|
338
|
+
managedChatsRoot,
|
|
332
339
|
});
|
|
333
340
|
};
|
|
334
341
|
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export const DEFAULT_INPUT_HISTORY_SCOPE = 'session';
|
|
2
|
+
export const DEFAULT_INPUT_HISTORY_LIMIT = 40;
|
|
3
|
+
const MIN_INPUT_HISTORY_LIMIT = 1;
|
|
4
|
+
const MAX_INPUT_HISTORY_LIMIT = 100;
|
|
5
|
+
|
|
6
|
+
export const isInputHistoryScope = (value) => (
|
|
7
|
+
value === 'global' || value === 'session'
|
|
8
|
+
);
|
|
9
|
+
|
|
10
|
+
export const isInputHistoryLimit = (value) => (
|
|
11
|
+
Number.isInteger(value)
|
|
12
|
+
&& value >= MIN_INPUT_HISTORY_LIMIT
|
|
13
|
+
&& value <= MAX_INPUT_HISTORY_LIMIT
|
|
14
|
+
);
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { parse as parseJsonc } from 'jsonc-parser';
|
|
2
|
+
|
|
3
|
+
const isJsonObject = (value) => value !== null && value !== undefined && Object.getPrototypeOf(value) === Object.prototype;
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Append a managed OpenChamber plugin to the `plugin` list of an
|
|
7
|
+
* `OPENCODE_CONFIG_CONTENT` value.
|
|
8
|
+
*
|
|
9
|
+
* Existing entries are preserved; an earlier entry for the same URL is dropped
|
|
10
|
+
* so a restart never registers the same plugin twice. Every managed plugin
|
|
11
|
+
* (agent tools, system prompt optimizer, MCP reconnect) goes through this one
|
|
12
|
+
* merge so they compose in any order.
|
|
13
|
+
*
|
|
14
|
+
* @param {string | undefined} rawConfig the current `OPENCODE_CONFIG_CONTENT`, JSONC or unset
|
|
15
|
+
* @param {string} pluginUrl `file://` URL of the materialized plugin
|
|
16
|
+
* @param {string} purpose short noun phrase for the error message, e.g. "managed tool"
|
|
17
|
+
* @returns {string} the merged config as JSON
|
|
18
|
+
*/
|
|
19
|
+
export const appendManagedPlugin = (rawConfig, pluginUrl, purpose) => {
|
|
20
|
+
const errors = [];
|
|
21
|
+
const text = (rawConfig ?? '').trim();
|
|
22
|
+
const parsed = text ? parseJsonc(text, errors, { allowTrailingComma: true }) : {};
|
|
23
|
+
if (errors.length > 0 || !isJsonObject(parsed)) {
|
|
24
|
+
throw new Error(`OPENCODE_CONFIG_CONTENT must contain a valid JSON object before OpenChamber can inject its ${purpose}`);
|
|
25
|
+
}
|
|
26
|
+
if (parsed.plugin !== undefined && !Array.isArray(parsed.plugin)) {
|
|
27
|
+
throw new Error(`OPENCODE_CONFIG_CONTENT plugin must be an array before OpenChamber can inject its ${purpose}`);
|
|
28
|
+
}
|
|
29
|
+
const configured = Array.isArray(parsed.plugin) ? parsed.plugin : [];
|
|
30
|
+
parsed.plugin = [
|
|
31
|
+
...configured.filter((value) => value !== pluginUrl && (!Array.isArray(value) || value[0] !== pluginUrl)),
|
|
32
|
+
pluginUrl,
|
|
33
|
+
];
|
|
34
|
+
return JSON.stringify(parsed);
|
|
35
|
+
};
|