From d62b11586b60272259c22f9a6fbf47470a11dbea Mon Sep 17 00:00:00 2001 From: Devvyn <22340871+opticon454@users.noreply.github.com> Date: Tue, 20 Oct 2026 06:23:17 +0800 Subject: [PATCH] feat(settings): optional sync of case options and selected case across devices --- .changeset/sync-case-state.md | 5 ++ docs/wiki/Settings-Reference.md | 1 + src/web/public/index.html | 7 +++ src/web/public/session-ui.js | 57 ++++++++++++++++++++ src/web/public/settings-ui.js | 3 ++ src/web/schemas.ts | 10 ++++ test/sync-case-state.test.ts | 93 +++++++++++++++++++++++++++++++++ 7 files changed, 176 insertions(+) create mode 100644 .changeset/sync-case-state.md create mode 100644 test/sync-case-state.test.ts diff --git a/.changeset/sync-case-state.md b/.changeset/sync-case-state.md new file mode 100644 index 00000000..7337a982 --- /dev/null +++ b/.changeset/sync-case-state.md @@ -0,0 +1,5 @@ +--- +"aicodeman": minor +--- + +Optional "Sync cases across devices" setting (Settings, off by default): keeps each case's Agent Teams and Opus 1M options on the server instead of in each browser, and switches a device to the case you last chose elsewhere when you return to its tab. diff --git a/docs/wiki/Settings-Reference.md b/docs/wiki/Settings-Reference.md index 2e952788..d85ea8c7 100644 --- a/docs/wiki/Settings-Reference.md +++ b/docs/wiki/Settings-Reference.md @@ -230,6 +230,7 @@ on. Details: [Custom Model Endpoints](Custom-Model-Endpoints#a-model-that-runs-o | Bypass approvals and sandbox | Starts new Codex sessions with `--dangerously-bypass-approvals-and-sandbox`. Read [Agent CLIs](Agent-CLIs) before enabling. | | Allow network access | Starts new local Codex sessions (Run menu, Resume and the API; not scheduled jobs) with `sandbox_workspace_write.network_access=true`, so commands in Codex's workspace-write sandbox can use the network. The [Codeman Skill](Codeman-Skill) needs it to reach this server from a Codex session. It applies to every such session, and those commands can then call Codeman's API, which can start a session outside the sandbox with no approval prompt, even when a password is set: see [Security](Security#what-codeman-does-not-protect-against). It applies where Codex runs the folder in workspace-write (a project you trusted in Codex) and does not change Codex's sandbox mode: a folder Codex runs read-only keeps the network closed, and Codex asks for approval per command. Synced, off by default; forced off for users without the privileged-commands grant in multi-user mode. | | Animated status effects | Cosmetic. | +| Sync cases across devices | Keeps each case's **Agent Teams** and **Opus 1M** options on the server (`caseSettings` in `settings.json`) and, when you return to a tab, switches it to the case you last chose on another device. Synced, off by default. Without it each browser keeps its own options for a case (the case list itself is always shared). The case names are stored in the shared settings file, so a non-admin in multi-user mode neither writes nor follows them. A device that already has options for a case keeps them until a device changes that case's options while this is on. | | MCP server sync | Copies the MCP servers each installed, enabled CLI (Claude, Codex, Gemini, OpenCode, Antigravity) and GitHub Copilot CLI has into the others' own config files. Synced, off by default, admin only in multi-user mode. Turn it on and **Apply** or **Save** (Apply keeps Settings open), then **Preview** shows what would change and **Sync now** applies it. It only adds missing servers, keeps the previous file as `.codeman-bak`, and leaves a file that receives env values or headers readable by you only. A config dir moved by `CODEX_HOME`, `CLAUDE_CONFIG_DIR`, `XDG_CONFIG_HOME`, `GEMINI_CLI_HOME` or `COPILOT_HOME` in Codeman's own environment is followed. | ### Notifications diff --git a/src/web/public/index.html b/src/web/public/index.html index f840e25f..2be94938 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -3572,6 +3572,13 @@ +
+
+ Sync cases across devices + Keeps each case's Agent Teams and Opus 1M options on the server and switches this device to the case you last chose on another one when you return to the tab. Off by default: each browser keeps its own. The case names are stored in the shared settings file. +
+ +
diff --git a/src/web/public/session-ui.js b/src/web/public/session-ui.js index bfc20f1f..09723635 100644 --- a/src/web/public/session-ui.js +++ b/src/web/public/session-ui.js @@ -613,6 +613,9 @@ Object.assign(CodemanApp.prototype, { this.updateCasePickerInput(select.value); }); select.dataset.listenerAdded = 'true'; + document.addEventListener('visibilitychange', () => { + if (document.visibilityState === 'visible') this._syncCaseStateFromServer(); + }); } this.setupQuickStartCasePicker(); // The phone overview labels rows with their case name, and a case rename or @@ -3670,6 +3673,60 @@ Object.assign(CodemanApp.prototype, { saveCaseSettings(caseName, settings) { localStorage.setItem('caseSettings_' + caseName, JSON.stringify(settings)); + if (this._caseStateSyncOn()) this._pushCaseSettings(caseName, settings); + }, + + // ── Opt-in case sync (`syncCaseState`): case options live on the server, the selected case follows other devices ── + + /** Off unless the user turned it on; a non-admin in multi-user mode cannot write the shared settings.json. */ + _caseStateSyncOn() { + return this.loadAppSettingsFromStorage().syncCaseState === true && !this._settingsReadOnly?.(); + }, + + async _pushCaseSettings(caseName, settings) { + try { + // PUT replaces the whole map, so merge into the server's current one rather than this device's copy. + const current = await fetch('/api/settings').then((r) => r.json()).then((env) => env?.data ?? {}); + await fetch('/api/settings', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + caseSettings: { + ...(current.caseSettings || {}), + [caseName]: { agentTeams: !!settings.agentTeams, opusContext1m: !!settings.opusContext1m }, + }, + }), + }); + } catch (err) { + console.error('Failed to sync case settings:', err); + } + }, + + /** Server copy wins: write the synced per-case options into the local keys getCaseSettings() reads. */ + _applyServerCaseSettings(server) { + if (server?.syncCaseState !== true || !server.caseSettings) return; + try { + for (const [name, s] of Object.entries(server.caseSettings)) { + localStorage.setItem('caseSettings_' + name, JSON.stringify(s)); + } + } catch { /* storage blocked: this device keeps what it has */ } + }, + + /** On returning to the tab: take the other devices' case options and selected case. */ + async _syncCaseStateFromServer() { + if (!this._caseStateSyncOn()) return; + try { + const server = await fetch('/api/settings').then((r) => r.json()).then((env) => env?.data ?? null); + this._applyServerCaseSettings(server); + const select = document.getElementById('quickStartCase'); + const last = server?.lastUsedCase; + if (select && last && last !== select.value && this.cases?.some((c) => c.name === last)) { + select.value = last; + this.updateDirDisplayForCase(last); + this.updateMobileCaseLabel(last); + this.updateCasePickerInput(last); + } + } catch { /* offline: try again next time the tab is shown */ } }, onCaseSettingChanged() { diff --git a/src/web/public/settings-ui.js b/src/web/public/settings-ui.js index 80360345..47c58a51 100644 --- a/src/web/public/settings-ui.js +++ b/src/web/public/settings-ui.js @@ -560,6 +560,7 @@ Object.assign(CodemanApp.prototype, { // .checked fires no onchange, so the list's visibility (and lazy load) // needs an explicit sync on every open, not just a save. this.applyCliManagementVisibility(); + document.getElementById('appSettingsSyncCaseState').checked = settings.syncCaseState === true; // MCP server sync: synced, default OFF; same explicit-sync reasoning as above. // The routes read the SAVED setting, so remember what it was on open: switching it on // here does nothing server-side until Save (see mcpSync()). @@ -3139,6 +3140,7 @@ Object.assign(CodemanApp.prototype, { customModelEndpointsEnabled: document.getElementById('appSettingsCustomModelEndpoints').checked, cliManagementEnabled: document.getElementById('appSettingsCliManagement').checked, mcpSyncEnabled: document.getElementById('appSettingsMcpSync').checked, + syncCaseState: document.getElementById('appSettingsSyncCaseState').checked, configBackupEnabled: document.getElementById('appSettingsConfigBackup').checked, configBackupDir: document.getElementById('appSettingsConfigBackupDir').value.trim(), ...this._configBackupRetentionPayload(_prev), @@ -5280,6 +5282,7 @@ Object.assign(CodemanApp.prototype, { // installs that had saved `false` (agent-skill-default-migration.ts). if (!('agentSkillEnabled' in appSettings)) delete merged.agentSkillEnabled; this.saveAppSettingsToStorage(merged); + this._applyServerCaseSettings(settings); // Apply notification prefs from server if present (only if localStorage has none) if (notificationPreferences && this.notificationManager) { diff --git a/src/web/schemas.ts b/src/web/schemas.ts index 5fb327c3..aa50cb63 100644 --- a/src/web/schemas.ts +++ b/src/web/schemas.ts @@ -1461,6 +1461,16 @@ export const SettingsUpdateSchema = z defaultClaudeMdPath: z.string().max(500).optional(), defaultWorkingDir: z.string().max(500).optional(), lastUsedCase: z.string().max(200).optional(), + /** + * Opt-in, SYNCED, default OFF: per-case run options (Agent Teams, Opus 1M) are kept here instead of + * only in each browser, and a device follows the selected case another device chose. + */ + syncCaseState: z.boolean().optional(), + /** Per-case run options, by case name; read and written only while `syncCaseState` is on. */ + caseSettings: z + .record(z.string().max(200), z.object({ agentTeams: z.boolean(), opusContext1m: z.boolean() }).strict()) + .refine((m) => Object.keys(m).length <= 500, 'too many cases') + .optional(), // Feature toggles ralphTrackerEnabled: z.boolean().optional(), subagentTrackingEnabled: z.boolean().optional(), diff --git a/test/sync-case-state.test.ts b/test/sync-case-state.test.ts new file mode 100644 index 00000000..b7415774 --- /dev/null +++ b/test/sync-case-state.test.ts @@ -0,0 +1,93 @@ +/** + * @fileoverview Opt-in case sync: the schema accepts the setting and a per-case map, and the + * session-ui helpers only touch the server when the setting is on. Evaluates the real session-ui.js. + */ +import { describe, expect, it } from 'vitest'; +import { readFileSync } from 'node:fs'; +import vm from 'node:vm'; +import { SettingsUpdateSchema } from '../src/web/schemas.js'; + +const SRC = readFileSync(new URL('../src/web/public/session-ui.js', import.meta.url), 'utf-8'); + +function makeApp(settings: Record, server: Record = {}) { + const store = new Map(); + const puts: unknown[] = []; + function CodemanApp(this: unknown) {} + const ctx = vm.createContext({ + CodemanApp, + window: {}, + document: { getElementById: () => null, addEventListener: () => {} }, + localStorage: { + getItem: (k: string) => store.get(k) ?? null, + setItem: (k: string, v: string) => void store.set(k, v), + }, + fetch: async (_url: string, init?: { method?: string; body?: string }) => { + if (init?.method === 'PUT') puts.push(JSON.parse(init.body!)); + return { ok: true, json: async () => ({ success: true, data: server }) }; + }, + console, + }); + vm.runInContext(SRC, ctx); + const app = Object.create((CodemanApp as unknown as { prototype: object }).prototype) as Record; + app.loadAppSettingsFromStorage = () => settings; + return { app, store, puts }; +} + +describe('syncCaseState', () => { + it('is accepted by the settings schema with a per-case map, and rejects junk', () => { + expect( + SettingsUpdateSchema.safeParse({ + syncCaseState: true, + caseSettings: { demo: { agentTeams: true, opusContext1m: false } }, + }).success + ).toBe(true); + expect(SettingsUpdateSchema.safeParse({ caseSettings: { demo: { agentTeams: 'yes' } } }).success).toBe(false); + expect( + SettingsUpdateSchema.safeParse({ caseSettings: { demo: { agentTeams: true, opusContext1m: true, x: 1 } } }) + .success + ).toBe(false); + }); + + it('saves only to this browser while the setting is off', async () => { + const { app, store, puts } = makeApp({}); + app.saveCaseSettings('demo', { agentTeams: true, opusContext1m: true }); + await new Promise((r) => setTimeout(r, 0)); + expect(store.get('caseSettings_demo')).toContain('agentTeams'); + expect(puts).toEqual([]); + }); + + it('pushes a merge into the server map while on, and never when settings are read-only', async () => { + const server = { caseSettings: { other: { agentTeams: false, opusContext1m: true } } }; + const on = makeApp({ syncCaseState: true }, server); + on.app.saveCaseSettings('demo', { agentTeams: true, opusContext1m: false }); + await new Promise((r) => setTimeout(r, 0)); + expect(on.puts).toEqual([ + { + caseSettings: { + other: { agentTeams: false, opusContext1m: true }, + demo: { agentTeams: true, opusContext1m: false }, + }, + }, + ]); + const ro = makeApp({ syncCaseState: true }); + ro.app._settingsReadOnly = () => true; + ro.app.saveCaseSettings('demo', { agentTeams: true, opusContext1m: true }); + await new Promise((r) => setTimeout(r, 0)); + expect(ro.puts).toEqual([]); + }); + + it('takes the server copy into local keys only while the server says sync is on', () => { + const a = makeApp({}); + a.app._applyServerCaseSettings({ + syncCaseState: true, + caseSettings: { demo: { agentTeams: true, opusContext1m: false } }, + }); + expect(JSON.parse(a.store.get('caseSettings_demo')!)).toEqual({ agentTeams: true, opusContext1m: false }); + const b = makeApp({}); + b.app._applyServerCaseSettings({ + syncCaseState: false, + caseSettings: { demo: { agentTeams: true, opusContext1m: false } }, + }); + expect(b.store.size).toBe(0); + }); +});