mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-14 02:29:33 +01:00
Merge #732: feat(settings): sync case options and selection across devices
This commit is contained in:
commit
4f29b0187a
23 files changed
+877
-37
No files matched your search
@@ -226,7 +226,7 @@ const VOLATILE_KEYS: Readonly<Record<string, readonly string[]>> = {
|
||||
// per user), a session started in another case, every captured prompt, and a Docker case's
|
||||
// conversation id after a /clear, and a web tab being opened.
|
||||
'users.json': ['lastLoginAt'],
|
||||
'settings.json': ['lastUsedCase'],
|
||||
'settings.json': ['lastUsedCase', 'lastPickedCase'],
|
||||
'intents.json': ['recentPrompts', 'updatedAt'],
|
||||
'docker-cases.json': ['lastClaudeSessionId'],
|
||||
'webviews.json': ['lastOpenedAt'],
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* @fileoverview Pure helpers for the opt-in shared per-case run options (`caseSettings` in
|
||||
* settings.json, written while `syncCaseState` is on). They run inside `mutateSettingsFile()`, so
|
||||
* every change is serialised with the file's other writers: two devices saving different cases
|
||||
* both land, where a replacement map would let the second discard the first.
|
||||
*/
|
||||
|
||||
export interface CaseRunOptions {
|
||||
agentTeams: boolean;
|
||||
opusContext1m: boolean;
|
||||
}
|
||||
|
||||
/** Most cases the shared map holds, counted after a merge (the schema bounds one request, not the file). */
|
||||
export const MAX_SHARED_CASES = 500;
|
||||
|
||||
export class SharedCaseLimitError extends Error {
|
||||
constructor() {
|
||||
super(`Shared case options are limited to ${MAX_SHARED_CASES} cases`);
|
||||
this.name = 'SharedCaseLimitError';
|
||||
}
|
||||
}
|
||||
|
||||
function asMap(value: unknown): Record<string, CaseRunOptions> {
|
||||
return value && typeof value === 'object' && !Array.isArray(value) ? (value as Record<string, CaseRunOptions>) : {};
|
||||
}
|
||||
|
||||
/** The stored map with `incoming` merged in per case. Throws SharedCaseLimitError past the limit. */
|
||||
export function mergeCaseSettings(current: unknown, incoming: Record<string, CaseRunOptions>) {
|
||||
const merged = { ...asMap(current), ...incoming };
|
||||
if (Object.keys(merged).length > MAX_SHARED_CASES) throw new SharedCaseLimitError();
|
||||
return merged;
|
||||
}
|
||||
|
||||
/** The map with `from` moved to `to`, or null when there is nothing to move. */
|
||||
export function renameCaseSettings(current: unknown, from: string, to: string) {
|
||||
const map = asMap(current);
|
||||
if (!Object.hasOwn(map, from)) return null;
|
||||
const { [from]: moved, ...rest } = map;
|
||||
return { ...rest, [to]: moved };
|
||||
}
|
||||
|
||||
/** The map without `name`, or null when it has no entry for it. */
|
||||
export function dropCaseSettings(current: unknown, name: string) {
|
||||
const map = asMap(current);
|
||||
if (!Object.hasOwn(map, name)) return null;
|
||||
const { [name]: _gone, ...rest } = map;
|
||||
return rest;
|
||||
}
|
||||
@@ -1739,6 +1739,11 @@
|
||||
(_m, what, why) =>
|
||||
`${what === 'Run mode' ? '运行模式' : '重生预设'}已保存到本设备,但未保存到服务器:${ZH_CN[why] || why}`,
|
||||
],
|
||||
// The case-options save (session-ui.js _pushCaseSettings): the case name passes through.
|
||||
[
|
||||
/^Options for "([^"]+)" \(until the next sync\) saved on this device, not on the server: ([\s\S]+)$/,
|
||||
(_m, name, why) => `案例“${name}”的选项已保存到本设备(同步前有效),但未保存到服务器:${ZH_CN[why] || why}`,
|
||||
],
|
||||
// A dialog read off a hook-less CLI's screen (src/web/screen-blocked.ts), as the
|
||||
// notification drawer shows it. The CLI's label passes through.
|
||||
[/^(.+) is waiting for your answer in the terminal$/, (_m, cli) => `${cli} 正在终端中等待你的回答`],
|
||||
|
||||
@@ -3572,6 +3572,13 @@
|
||||
</div>
|
||||
<input type="text" id="appSettingsDefaultDir" class="set-input" placeholder="~/projects">
|
||||
</div>
|
||||
<div class="set-row" data-search="sync cases across devices selected case agent teams opus 1m">
|
||||
<div class="set-row-text">
|
||||
<span class="set-row-label">Sync cases across devices</span>
|
||||
<span class="set-row-desc">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.</span>
|
||||
</div>
|
||||
<label class="switch switch-sm"><input type="checkbox" id="appSettingsSyncCaseState" aria-label="Sync cases across devices"><span class="slider"></span></label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -640,15 +643,29 @@ Object.assign(CodemanApp.prototype, {
|
||||
// Multi-user non-admin: lastUsedCase is a key of the server-wide settings.json, whose
|
||||
// PUT is admin-only (403), so it is not sent (_settingsReadOnly, settings-ui.js).
|
||||
if (this._settingsReadOnly?.()) return;
|
||||
try {
|
||||
await fetch('/api/settings', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ lastUsedCase: caseName })
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Failed to save last used case:', err);
|
||||
}
|
||||
// lastPickedCase is only what a person chose on a device (never a quick-start or an agent's spawn),
|
||||
// so it is what other devices follow when case sync is on.
|
||||
const sync = this._caseStateSyncOn?.() === true;
|
||||
const body = sync ? { lastUsedCase: caseName, lastPickedCase: caseName } : { lastUsedCase: caseName };
|
||||
const send = async () => {
|
||||
let stored = false;
|
||||
try {
|
||||
const res = await fetch('/api/settings', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
stored = res?.ok !== false;
|
||||
} catch (err) {
|
||||
console.error('Failed to save last used case:', err);
|
||||
}
|
||||
// While this device's own pick is not on the server, the next tab return must not follow the server's older one.
|
||||
if (sync) this._ownPickNotStored = !stored;
|
||||
};
|
||||
if (!sync) return send();
|
||||
// On the chain the tab-return refresh already awaits, so a pick lands before the copy it reads.
|
||||
this._caseSyncChain = (this._caseSyncChain || Promise.resolve()).then(send, send);
|
||||
return this._caseSyncChain;
|
||||
},
|
||||
|
||||
async quickStart() {
|
||||
@@ -3670,6 +3687,111 @@ Object.assign(CodemanApp.prototype, {
|
||||
|
||||
saveCaseSettings(caseName, settings) {
|
||||
localStorage.setItem('caseSettings_' + caseName, JSON.stringify(settings));
|
||||
if (this._caseStateSyncOn()) this._queueCaseSync(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?.();
|
||||
},
|
||||
|
||||
/**
|
||||
* Send one case's options. Saves from this device run one after another in the order they were made
|
||||
* (one chain), and each carries only its own case: the server merges it into the shared map, so a save
|
||||
* from another device is never overwritten. `_caseSyncPending` counts a case's saves still in flight,
|
||||
* so an import of the server's copy leaves that case alone.
|
||||
*/
|
||||
_queueCaseSync(caseName, settings) {
|
||||
const entry = { agentTeams: !!settings.agentTeams, opusContext1m: !!settings.opusContext1m };
|
||||
const pending = this._caseSyncPending || (this._caseSyncPending = new Map());
|
||||
pending.set(caseName, (pending.get(caseName) || 0) + 1);
|
||||
const run = async () => {
|
||||
try {
|
||||
await this._pushCaseSettings(caseName, entry);
|
||||
} finally {
|
||||
const left = (pending.get(caseName) || 1) - 1;
|
||||
if (left > 0) pending.set(caseName, left);
|
||||
else pending.delete(caseName);
|
||||
}
|
||||
};
|
||||
this._caseSyncChain = (this._caseSyncChain || Promise.resolve()).then(run, run);
|
||||
return this._caseSyncChain;
|
||||
},
|
||||
|
||||
async _pushCaseSettings(caseName, entry) {
|
||||
let res = null;
|
||||
try {
|
||||
res = await fetch('/api/settings', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ caseSettings: { [caseName]: entry } }),
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Failed to sync case settings:', err);
|
||||
}
|
||||
// A refusal or failure keeps the choice on this device only until the next sync brings the server's copy back.
|
||||
await this._noteSettingsWriteResult?.(res, `Options for "${caseName}" (until the next sync)`);
|
||||
},
|
||||
|
||||
/** Server copy wins, except for a case this device is still saving: write its options into the local keys getCaseSettings() reads. */
|
||||
_applyServerCaseSettings(server) {
|
||||
if (server?.syncCaseState !== true || !server.caseSettings || typeof server.caseSettings !== 'object') return;
|
||||
// A non-admin never receives the map (the server redacts it) and must not apply one it was handed.
|
||||
if (this._settingsReadOnly?.()) return;
|
||||
try {
|
||||
let changed = false;
|
||||
for (const [name, s] of Object.entries(server.caseSettings)) {
|
||||
if (!s || typeof s !== 'object' || this._caseSyncPending?.has(name)) continue;
|
||||
const next = JSON.stringify({ agentTeams: s.agentTeams === true, opusContext1m: s.opusContext1m === true });
|
||||
if (localStorage.getItem('caseSettings_' + name) === next) continue;
|
||||
localStorage.setItem('caseSettings_' + name, next);
|
||||
changed = true;
|
||||
}
|
||||
// An open popover still shows the options it was filled with: closing it keeps a tick from writing them into another case.
|
||||
if (changed) this._closeCaseSettingsPopovers();
|
||||
} catch { /* storage blocked: this device keeps what it has */ }
|
||||
},
|
||||
|
||||
_closeCaseSettingsPopovers() {
|
||||
document.getElementById('caseSettingsPopover')?.classList.add('hidden');
|
||||
document.getElementById('caseSettingsPopoverMobile')?.classList.add('hidden');
|
||||
},
|
||||
|
||||
/** The server decides whether sync is on: when it says otherwise, this device's cached switch follows it. */
|
||||
_adoptServerCaseSyncPreference(server) {
|
||||
if (typeof server?.syncCaseState !== 'boolean') return;
|
||||
const cached = this.loadAppSettingsFromStorage();
|
||||
if ((cached.syncCaseState === true) === server.syncCaseState) return;
|
||||
this.saveAppSettingsToStorage?.({ ...cached, syncCaseState: server.syncCaseState });
|
||||
},
|
||||
|
||||
/** On returning to the tab: take the other devices' case options and selected case, if the server has sync on. */
|
||||
async _syncCaseStateFromServer() {
|
||||
if (this._settingsReadOnly?.()) return;
|
||||
// Off on this device: no request on every tab return. Turning it on elsewhere is learned from the
|
||||
// next settings load; turning it off elsewhere still reaches a device whose cached switch is on.
|
||||
if (this.loadAppSettingsFromStorage?.()?.syncCaseState !== true) return;
|
||||
const seq = (this._caseSyncRefresh = (this._caseSyncRefresh || 0) + 1);
|
||||
try {
|
||||
const chain = this._caseSyncChain;
|
||||
await chain; // this device's own saves land first, so the copy read next includes them
|
||||
const res = await fetch('/api/settings');
|
||||
if (!res.ok) return;
|
||||
const server = (await res.json())?.data ?? null;
|
||||
if (seq !== this._caseSyncRefresh || !server) return; // a newer refresh owns the answer
|
||||
if (this._caseSyncChain !== chain) return; // a save or pick made while the read was out is newer than it
|
||||
this._adoptServerCaseSyncPreference(server);
|
||||
if (server.syncCaseState !== true) return;
|
||||
this._applyServerCaseSettings(server);
|
||||
const select = document.getElementById('quickStartCase');
|
||||
const last = server.lastPickedCase;
|
||||
if (select && last && !this._ownPickNotStored && last !== select.value && this.cases?.some((c) => c.name === last)) {
|
||||
this._closeCaseSettingsPopovers();
|
||||
this.selectQuickStartCase(last, { save: false });
|
||||
}
|
||||
} catch { /* offline: try again next time the tab is shown */ }
|
||||
},
|
||||
|
||||
onCaseSettingChanged() {
|
||||
|
||||
@@ -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),
|
||||
@@ -5243,7 +5245,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
const settings = settingsPromise ? await settingsPromise : await fetch('/api/settings').then(r => r.ok ? r.json() : null).then(env => env?.success === true ? env.data : env);
|
||||
if (settings) {
|
||||
// Extract notification prefs before merging app settings
|
||||
const { notificationPreferences, voiceSettings, respawnPresets, runMode, ...appSettings } = settings;
|
||||
const { notificationPreferences, voiceSettings, respawnPresets, runMode, caseSettings: _sharedCases, ...appSettings } = settings;
|
||||
// Display settings are device-specific (mobile vs desktop) and must not be synced
|
||||
// from the server: see _displaySettingKeySet().
|
||||
const displayKeys = this._displaySettingKeySet();
|
||||
@@ -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) {
|
||||
|
||||
@@ -87,6 +87,7 @@ import {
|
||||
settingsWriteErrorResponse,
|
||||
} from '../settings-writer.js';
|
||||
import { LinkedCasesWriteRefusedError, linkedCasesPath, mutateLinkedCases } from '../linked-cases-writer.js';
|
||||
import { dropCaseSettings, renameCaseSettings } from '../case-state-sync.js';
|
||||
import { isMultiUserMode } from '../../config/multiuser.js';
|
||||
import type { AuthUser } from '../../types.js';
|
||||
import { SseEvent } from '../sse-events.js';
|
||||
@@ -1747,6 +1748,14 @@ export function registerCaseRoutes(
|
||||
// a stale value is ignored at creation anyway).
|
||||
// An admin deleting another user's remote/Docker case clears that owner's too.
|
||||
const forgetCaseDefault = async (caseOwner?: string): Promise<void> => {
|
||||
// The shared run options (syncCaseState) are the admin's server-wide map: a non-admin's own case of
|
||||
// the same name must leave them alone. Best-effort, like the default group.
|
||||
if (isAdmin(req)) {
|
||||
await mutateSettingsFile((current) => {
|
||||
const rest = dropCaseSettings(current.caseSettings, name);
|
||||
return rest ? { ...current, caseSettings: rest } : undefined;
|
||||
}).catch(() => undefined);
|
||||
}
|
||||
const owners = new Set([ownerLayoutKey(ownerFor(req)), ...(caseOwner ? [ownerLayoutKey(caseOwner)] : [])]);
|
||||
for (const owner of owners) {
|
||||
try {
|
||||
@@ -2015,6 +2024,7 @@ export function registerCaseRoutes(
|
||||
// The settings.json values this rename replaced, set only once it wrote them. A failure puts back
|
||||
// these keys alone, so a settings save that landed in between is kept.
|
||||
let settingsUndo: Record<string, unknown> | undefined;
|
||||
let sharedRenamed = false;
|
||||
try {
|
||||
// settings.json is server-wide and caseOrder/lastUsedCase name the admin's cases, so only the
|
||||
// admin's rename touches it (a non-admin's own rename must not rewrite someone else's list).
|
||||
@@ -2023,12 +2033,24 @@ export function registerCaseRoutes(
|
||||
const { before, after } = await mutateSettingsFile((existing) => {
|
||||
const order = renameInOrder(existing.caseOrder, name, newName);
|
||||
const last = existing.lastUsedCase === name ? newName : undefined;
|
||||
if (!order && !last) return undefined;
|
||||
return { ...existing, ...(order ? { caseOrder: order } : {}), ...(last ? { lastUsedCase: last } : {}) };
|
||||
const picked = existing.lastPickedCase === name ? newName : undefined;
|
||||
// The shared run options (syncCaseState) follow the case to its new name.
|
||||
const shared = renameCaseSettings(existing.caseSettings, name, newName);
|
||||
if (!order && !last && !picked && !shared) return undefined;
|
||||
return {
|
||||
...existing,
|
||||
...(order ? { caseOrder: order } : {}),
|
||||
...(last ? { lastUsedCase: last } : {}),
|
||||
...(picked ? { lastPickedCase: picked } : {}),
|
||||
...(shared ? { caseSettings: shared } : {}),
|
||||
};
|
||||
});
|
||||
if (after) {
|
||||
const keys = ['caseOrder', 'lastUsedCase'].filter((k) => after[k] !== before[k]);
|
||||
// caseSettings is undone by renaming its entry back (below), not by restoring the whole map, so a
|
||||
// per-case save from another device that landed since is kept.
|
||||
const keys = ['caseOrder', 'lastUsedCase', 'lastPickedCase'].filter((k) => after[k] !== before[k]);
|
||||
settingsUndo = Object.fromEntries(keys.map((k) => [k, before[k]]));
|
||||
sharedRenamed = after.caseSettings !== before.caseSettings;
|
||||
}
|
||||
}
|
||||
if (isLinked) {
|
||||
@@ -2049,7 +2071,12 @@ export function registerCaseRoutes(
|
||||
// The folder moved but the registries did not follow: put settings.json and the folder back
|
||||
// rather than leave a case whose name and folder disagree.
|
||||
const undo = settingsUndo;
|
||||
if (undo) await mutateSettingsFile((current) => ({ ...current, ...undo })).catch(() => {});
|
||||
if (undo) {
|
||||
await mutateSettingsFile((current) => {
|
||||
const back = sharedRenamed ? renameCaseSettings(current.caseSettings, newName, name) : null;
|
||||
return { ...current, ...undo, ...(back ? { caseSettings: back } : {}) };
|
||||
}).catch(() => {});
|
||||
}
|
||||
if (moveFolder) {
|
||||
const movedBack = await fs.rename(newPath, oldPath).then(
|
||||
() => true,
|
||||
|
||||
@@ -49,6 +49,7 @@ import {
|
||||
SETTINGS_PATH,
|
||||
} from '../route-helpers.js';
|
||||
import { mutateSettingsFile, settingsWriteErrorResponse } from '../settings-writer.js';
|
||||
import { mergeCaseSettings, SharedCaseLimitError } from '../case-state-sync.js';
|
||||
import { SseEvent } from '../sse-events.js';
|
||||
import { getInstallInfo, checkForUpdate, startUpdate, getUpdateStatusForApi } from '../self-update.js';
|
||||
|
||||
@@ -160,8 +161,8 @@ export function resolveSpanUrl(hostHeader: string | undefined, fallbackPort = '3
|
||||
* What GET /api/settings answers a NON-ADMIN in multi-user mode: settings.json minus what
|
||||
* belongs to the server's admin rather than to the reader. `voiceSettings.apiKey` is the
|
||||
* Deepgram key an admin saved, a credential (the reader's browser seeded it into its own
|
||||
* voice config and dictated on the admin's account); `caseOrder` and `lastUsedCase` name the
|
||||
* admin's cases. Everything else stays readable, because the page reads the synced feature
|
||||
* voice config and dictated on the admin's account); `caseOrder`, `lastUsedCase`, `lastPickedCase` and `caseSettings` name
|
||||
* the admin's cases. Everything else stays readable, because the page reads the synced feature
|
||||
* switches from it. Writes are admin-only there (PUT /api/settings). Exported for unit testing.
|
||||
*/
|
||||
export function settingsForNonAdmin(settings: unknown): unknown {
|
||||
@@ -169,6 +170,8 @@ export function settingsForNonAdmin(settings: unknown): unknown {
|
||||
const out: Record<string, unknown> = { ...(settings as Record<string, unknown>) };
|
||||
delete out.caseOrder;
|
||||
delete out.lastUsedCase;
|
||||
delete out.lastPickedCase;
|
||||
delete out.caseSettings;
|
||||
const voice = out.voiceSettings;
|
||||
if (voice && typeof voice === 'object' && !Array.isArray(voice) && 'apiKey' in voice) {
|
||||
const voiceOut: Record<string, unknown> = { ...(voice as Record<string, unknown>) };
|
||||
@@ -1141,8 +1144,18 @@ export function registerSystemRoutes(
|
||||
// It must never write tunnelEnabled:true: a concurrent stop has written false by now,
|
||||
// and true on disk would show a stopped tunnel as on. The stored value stays as it is.
|
||||
if (settings.tunnelEnabled === true && !tunnelEnableAuthorized) delete settingsToStore.tunnelEnabled;
|
||||
const { before: existing } = await mutateSettingsFile((current) => ({ ...current, ...settingsToStore }));
|
||||
const merged = { ...existing, ...settingsToStore };
|
||||
// caseSettings merges per case inside the serialised writer, so two devices saving different cases both land.
|
||||
const { caseSettings: incomingCases, ...plainSettings } = settingsToStore;
|
||||
// The map is read and written only while syncCaseState is on, so a PUT that carries it with the setting off
|
||||
// (as stored, or as this same PUT sets it) leaves the stored map alone.
|
||||
const { before: existing, after } = await mutateSettingsFile((current) => ({
|
||||
...current,
|
||||
...plainSettings,
|
||||
...(incomingCases && (plainSettings.syncCaseState ?? current.syncCaseState) === true
|
||||
? { caseSettings: mergeCaseSettings(current.caseSettings, incomingCases as never) }
|
||||
: {}),
|
||||
}));
|
||||
const merged = after ?? { ...existing, ...plainSettings };
|
||||
|
||||
// tmux 3.7+ resizes tracked panes; older versions apply this to new panes.
|
||||
// Already-evicted history cannot be recovered on either version.
|
||||
@@ -1244,6 +1257,12 @@ export function registerSystemRoutes(
|
||||
|
||||
return {};
|
||||
} catch (err) {
|
||||
if (err instanceof SharedCaseLimitError) {
|
||||
throw Object.assign(err, {
|
||||
statusCode: 400,
|
||||
body: createErrorResponse(ApiErrorCode.INVALID_INPUT, err.message),
|
||||
});
|
||||
}
|
||||
return settingsWriteErrorResponse(err);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1461,6 +1461,18 @@ export const SettingsUpdateSchema = z
|
||||
defaultClaudeMdPath: z.string().max(500).optional(),
|
||||
defaultWorkingDir: z.string().max(500).optional(),
|
||||
lastUsedCase: z.string().max(200).optional(),
|
||||
/** The case a person last picked in a case picker; what other devices follow while `syncCaseState` is on. */
|
||||
lastPickedCase: 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(),
|
||||
|
||||
Reference in new issue
Block a user