Merge #732: feat(settings): sync case options and selection across devices

This commit is contained in:
Codeman maintainer committed 2026-11-09 08:01:43 +01:00
commit 4f29b0187a
23 files changed
+877 -37

No files matched your search

+5
View File
@@ -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.
+2 -2
View File
@@ -186,7 +186,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
**DeepSeek web UI** (`POST`/`GET`/`DELETE /api/deepseek/web`, `deepseek-web-server.ts`): the Run menu's "DeepSeek web UI..." entry supervises ONE background `dsh web` child process, deliberately **NOT a shell session**. ⚠️ Every piece is load-bearing: exactly one server (a second click REUSES it), restarted when the browser authority changes (`--trusted-host`, last asker wins), killed on shutdown via `stopDeepSeekWeb()` (the detached child would otherwise outlive Codeman and hold its port), and failures returned to the caller. ⚠️ Never hardcode the port: search from 3080 across 40, detect free ports by BINDING, then wait for the server to really answer. ⚠️ Both `POST` and `DELETE` must stay behind `canUsernameRunPrivilegedCommands` (booting a profile runs its plugin code; the server is shared). → [architecture-invariants#deepseek-web-ui](docs/architecture-invariants.md#deepseek-web-ui)
**Case rename** (`POST /api/cases/:name/rename`, `src/case-rename.ts`; `docs/wiki/Core-Concepts.md#case`): a case under the cases dir is its folder name and always moves; a linked case renames its alias and moves its real folder only with `renameFolder`; remote and Docker cases are refused. ⚠️ A folder that sessions have open is the hazard: live sessions at or under it make the route answer `409 requiresStop` with their names, and only `stopSessions: true` ends them (via `ctx.cleanupSession`: closed like a tab close, uploads included, nothing is restarted); the free-target pre-check runs before that and refuses a dangling symlink too (`lstat`, as `moveCaseFolder` does), so no session is ended for a move that cannot happen. `withCaseLock` holds the old and new paths for the whole operation (`isCasePathLocked`; the `Session` constructor throws for a working directory inside a held path, so nothing is constructed mid-move). ⚠️ A create awaits the owner's layout lock and a settings read between `new Session` and the server's map, where the rename looks for open sessions, so the constructor also records the session (`noteCaseSessionStarting`) until `registerSessionWithLayout()` or `stop()` settles it (30 s TTL for one that never registers), and a folder move refuses `409` with `renameCode: "BUSY"` while one is recorded at or under the folder (`isCaseSessionStarting`, checked in the same tick that takes the lock); an alias-only rename is not refused. A create whose path was validated before the move but whose `Session` is constructed after it is not covered (the plain check-then-use race of any create path), `moveCaseFolder` refuses an existing target itself (POSIX `rename` would silently replace an EMPTY directory) and retries `EBUSY`/`EPERM`/`EACCES` before answering `423 LOCKED`. If the registries (`linked-cases.json`, `caseOrder`, `lastUsedCase`) fail after the move, `settings.json` and the folder are put back (a failed move-back says the folder is now at the new path), and an error after `stopSessions` carries `stoppedSessions`. Claude Code keys history by path (`claudeProjectDirName`: every non-alphanumeric becomes `-`), so `<claude config>/projects/<old>` is renamed too, or merged entry by entry, never overwriting, into one already at the new key (case delete never removes history, so an earlier case of the new name leaves one); other CLIs' history, Docker bind mounts, `git worktree` links, cron jobs' `workingDir`, Claude history of subfolder sessions and Claude Code's per-project entry in `~/.claude.json` (local-scope `mcpServers`, `allowedTools`, trust; writing another CLI's config is the opt-in MCP sync's job) are NOT migrated. The history scanner prefers the decoded project key over a transcript's `cwd` that no longer exists, so Resume follows a rename. Only an admin's rename touches the server-wide `settings.json` (`caseOrder`, `lastUsedCase`); the owner's default session group is carried across with `TabLayoutService`. `settings.json` is written before `linked-cases.json`, so a failed or refused registry write puts `settings.json` back and leaves the registry as it was. ⚠️ Every write to `linked-cases.json` (link, unlink, rename, and a create or clone into a custom folder) goes through `mutateLinkedCases()` (`src/web/linked-cases-writer.ts`: serialized, temp file + rename, and a file that does not parse is refused, never read as `{}`; the one direct writer is config-backup restore, `restoreConfigBackup()`, which must work on such a file), and a check that must still hold when the entry is written (the name is free; for a rename, the old entry still names the folder) runs INSIDE its callback: two clones that claimed one name used to both pass a re-read made outside it and both answer success. Pinned by `test/linked-cases-writer.test.ts`, and for a rename by `test/routes/case-rename-routes.test.ts`.
**Case rename** (`POST /api/cases/:name/rename`, `src/case-rename.ts`; `docs/wiki/Core-Concepts.md#case`): a case under the cases dir is its folder name and always moves; a linked case renames its alias and moves its real folder only with `renameFolder`; remote and Docker cases are refused. ⚠️ A folder that sessions have open is the hazard: live sessions at or under it make the route answer `409 requiresStop` with their names, and only `stopSessions: true` ends them (via `ctx.cleanupSession`: closed like a tab close, uploads included, nothing is restarted); the free-target pre-check runs before that and refuses a dangling symlink too (`lstat`, as `moveCaseFolder` does), so no session is ended for a move that cannot happen. `withCaseLock` holds the old and new paths for the whole operation (`isCasePathLocked`; the `Session` constructor throws for a working directory inside a held path, so nothing is constructed mid-move). ⚠️ A create awaits the owner's layout lock and a settings read between `new Session` and the server's map, where the rename looks for open sessions, so the constructor also records the session (`noteCaseSessionStarting`) until `registerSessionWithLayout()` or `stop()` settles it (30 s TTL for one that never registers), and a folder move refuses `409` with `renameCode: "BUSY"` while one is recorded at or under the folder (`isCaseSessionStarting`, checked in the same tick that takes the lock); an alias-only rename is not refused. A create whose path was validated before the move but whose `Session` is constructed after it is not covered (the plain check-then-use race of any create path), `moveCaseFolder` refuses an existing target itself (POSIX `rename` would silently replace an EMPTY directory) and retries `EBUSY`/`EPERM`/`EACCES` before answering `423 LOCKED`. If the registries (`linked-cases.json`, `caseOrder`, `lastUsedCase`, `lastPickedCase`, `caseSettings`) fail after the move, `settings.json` and the folder are put back (a failed move-back says the folder is now at the new path), and an error after `stopSessions` carries `stoppedSessions`. Claude Code keys history by path (`claudeProjectDirName`: every non-alphanumeric becomes `-`), so `<claude config>/projects/<old>` is renamed too, or merged entry by entry, never overwriting, into one already at the new key (case delete never removes history, so an earlier case of the new name leaves one); other CLIs' history, Docker bind mounts, `git worktree` links, cron jobs' `workingDir`, Claude history of subfolder sessions and Claude Code's per-project entry in `~/.claude.json` (local-scope `mcpServers`, `allowedTools`, trust; writing another CLI's config is the opt-in MCP sync's job) are NOT migrated. The history scanner prefers the decoded project key over a transcript's `cwd` that no longer exists, so Resume follows a rename. Only an admin's rename touches the server-wide `settings.json` (`caseOrder`, `lastUsedCase`, `lastPickedCase`, `caseSettings`); the owner's default session group is carried across with `TabLayoutService`. `settings.json` is written before `linked-cases.json`, so a failed or refused registry write puts `settings.json` back and leaves the registry as it was. ⚠️ Every write to `linked-cases.json` (link, unlink, rename, and a create or clone into a custom folder) goes through `mutateLinkedCases()` (`src/web/linked-cases-writer.ts`: serialized, temp file + rename, and a file that does not parse is refused, never read as `{}`; the one direct writer is config-backup restore, `restoreConfigBackup()`, which must work on such a file), and a check that must still hold when the entry is written (the name is free; for a rename, the old entry still names the folder) runs INSIDE its callback: two clones that claimed one name used to both pass a re-read made outside it and both answer success. Pinned by `test/linked-cases-writer.test.ts`, and for a rename by `test/routes/case-rename-routes.test.ts`.
**Custom Model Endpoint Profiles** (opt-in, `customModelEndpointsEnabled`, SYNCED, default OFF; `docs/custom-model-endpoints.md`): points a session at a user-configured OpenAI-compatible endpoint (store `custom-model-hosts.ts`, `~/.codeman/custom-model-hosts.json`, 0600). `authStyle` is `bearer` or `api-key`, never both headers (hangs the server). The per-CLI redirect is registry data, `capabilities.customModelInjection` (`env` / `configContentEnv` / `configDir` / `unsupported`), computed by the pure `custom-model-injection.ts`; ⚠️ `configDir` writes an isolated per-session config, NEVER the user's real `~/.codex`/`~/.pi`/`~/.omp`/grok config; the one exception is a recipe's `linkFromRealHome` (deepseek's `profiles`, `sessions`, `settings.yaml`), which symlinks those named entries back out of the real home so they survive the redirect. ⚠️ Claude applies via `Session.restartCli()` (a relaunch in the existing pane, so the conversation is pinned as `resumeSessionId`), the rest one-shot at launch; retired env keys must also be `setenv -u`'d (`_pendingEnvUnsets`), since tmux env survives `respawn-pane`. ⚠️ Remote/Docker sessions are refused (400). ⚠️ Persist only KEYS (`__customModel`), never values (they carry the API key); the one exception is `ownEnv`'s `homeEnvVar` entry, the session's OWN home path (absolute only: a `clis.json` override can name any variable there), which `setCustomModel()` puts back on clear and recovery clamps, since the selection overwrote the tmux `setenv` that held it. ⚠️ Every redirectable var must be in that CLI's `privilegedEnvKeys`, and `ANTHROPIC_*` stays out of claude's `allowedPrefixes`. ⚠️ The Run-menu picker builds entries from `window.__codemanCustomModelClis` (escaped via `escapeScriptJson()`), never a hardcoded CLI id list, and launches through `run()` via a temporary `_runMode` swap, never `setRunMode()`. ⚠️ Every write of `custom-model-hosts.json` goes through `mutateCustomModelHosts()` (`src/custom-model-hosts-writer.ts`: one at a time, temp file + rename, the route's check inside the callback, and a file that does not parse is refused with 409, never read as `[]` and replaced); the one direct writer is config-backup restore. The endpoint id `local-ollama` is reserved for the local model install (see Local model install). Pinned by `test/custom-model-hosts-writer.test.ts`. → [architecture-invariants#custom-model-endpoint-profiles](docs/architecture-invariants.md#custom-model-endpoint-profiles)
@@ -267,7 +267,7 @@ The terminal provider scans `terminalLinkLine()`, which extends `terminalPathLin
**Web tabs** (dashboard URLs as tabs): a saved URL renders as a tab beside sessions, **NOT a `SessionMode`**, and is proxied through Codeman's own origin (`/webview/<cap>/...`). ⚠️ The proxy is NOT an API surface: its capability-based auth exemption stays fenced to safe methods and non-route paths (`test/webview-auth-exemption.test.ts`). ⚠️ Iframes omit `allow-same-origin` unless `trusted`, and `Authorization`/`codeman_session` are stripped upstream in both modes. ⚠️ **Egress guard**: link-local and cloud-metadata targets are refused at save time, by a sync hostname check at each connect (IP literals skip DNS), AND on the resolved address (`webview-egress.ts`); use the `undici` package's own `fetch` + `Agent`, never Node's global fetch. ⚠️ Loopback links in agent output auto-open as proxied web tabs (`openLinkThroughWebTabIfLoopback`), but never auto-route `*.localhost` (prompt-injectable DNS). Capabilities are revoked on logout (`revokeOwner`). → [architecture-invariants#web-tabs](docs/architecture-invariants.md#web-tabs), `docs/web-tabs.md`
**Multi-user mode** (opt-in `--multiuser` / `CODEMAN_MULTIUSER=1`, OFF by default): named users with scrypt-hashed passwords in `~/.codeman/users.json`. Gated everywhere by `isMultiUserMode()`; when OFF, behavior is byte-identical to single-user because every scoping helper short-circuits. ⚠️ **Not a security boundary at the agent layer**: every session still runs as the SAME OS account. This separates WORKSPACES; it does not sandbox users (Docker cases are the isolation story). Ownership threads through `Session.owner` and is enforced in `findSessionOrFail`, list endpoints, SSE routing (fail-closed), WS, search, and file-preview. ⚠️ `settings.json` is ONE server-wide file, so every write to it is admin-only (`PUT /api/settings`, `/api/execution/model-config`, `/api/cases/order`, plus `PUT /api/config`: 403 `FORBIDDEN` before parsing), a non-admin's quick-start/Ralph start skips `lastUsedCase`, its GET hides `voiceSettings.apiKey`/`caseOrder`/`lastUsedCase` from a non-admin (`settingsForNonAdmin`), and the browser skips those writes for a non-admin via `_settingsReadOnly()` (settings-ui.js; also true once a write came back 403 `FORBIDDEN`, since `/api/me` can be slow or fail; their run mode and respawn presets stay per-device, `_applyOwnSettings`) (`test/routes/system-routes-settings-admin-only.test.ts`). System ops are admin-only too (`/api/system/update`, `/api/system/span-displays`, `/api/tunnel/qr/regenerate`, `/api/cleanup-state`), and a non-admin's `/api/auth/revoke` revokes only their own logins (`test/routes/system-routes-system-ops-admin-only.test.ts`). → [architecture-invariants#multi-user-mode](docs/architecture-invariants.md#multi-user-mode), `docs/multi-user-plan.md`
**Multi-user mode** (opt-in `--multiuser` / `CODEMAN_MULTIUSER=1`, OFF by default): named users with scrypt-hashed passwords in `~/.codeman/users.json`. Gated everywhere by `isMultiUserMode()`; when OFF, behavior is byte-identical to single-user because every scoping helper short-circuits. ⚠️ **Not a security boundary at the agent layer**: every session still runs as the SAME OS account. This separates WORKSPACES; it does not sandbox users (Docker cases are the isolation story). Ownership threads through `Session.owner` and is enforced in `findSessionOrFail`, list endpoints, SSE routing (fail-closed), WS, search, and file-preview. ⚠️ `settings.json` is ONE server-wide file, so every write to it is admin-only (`PUT /api/settings`, `/api/execution/model-config`, `/api/cases/order`, plus `PUT /api/config`: 403 `FORBIDDEN` before parsing), a non-admin's quick-start/Ralph start skips `lastUsedCase`, its GET hides `voiceSettings.apiKey`/`caseOrder`/`lastUsedCase`/`lastPickedCase`/`caseSettings` from a non-admin (`settingsForNonAdmin`), and the browser skips those writes for a non-admin via `_settingsReadOnly()` (settings-ui.js; also true once a write came back 403 `FORBIDDEN`, since `/api/me` can be slow or fail; their run mode and respawn presets stay per-device, `_applyOwnSettings`) (`test/routes/system-routes-settings-admin-only.test.ts`). System ops are admin-only too (`/api/system/update`, `/api/system/span-displays`, `/api/tunnel/qr/regenerate`, `/api/cleanup-state`), and a non-admin's `/api/auth/revoke` revokes only their own logins (`test/routes/system-routes-system-ops-admin-only.test.ts`). → [architecture-invariants#multi-user-mode](docs/architecture-invariants.md#multi-user-mode), `docs/multi-user-plan.md`
**Away digest**: `GET /api/away-digest` aggregates what happened while you were away from the lifecycle log, run-summary events, live sessions, token stats, and recent subagents. Pure aggregator in `web/away-digest.ts`. ⚠️ Returns `{success:true,digest}`, a legacy raw-ish shape consistent with the other raw GET handlers in `system-routes.ts`; frontend and tests read `.digest`. → [architecture-invariants#away-digest](docs/architecture-invariants.md#away-digest)
+1
View File
@@ -51,6 +51,7 @@ export const BROWSER_TEST_GLOBS = [
'test/doctor-settings.browser.test.ts',
'test/git-status.browser.test.ts',
'test/edit-history.browser.test.ts',
'test/sync-case-state.browser.test.ts',
'test/split-pane-orchestration.browser.test.ts',
'test/split-pane-auto-collapse.browser.test.ts',
'test/spreadsheet-preview.browser.test.ts',
+4 -4
View File
@@ -1614,12 +1614,12 @@ A local case whose folder is the top of a git repository can keep **worktrees**
## Settings
`~/.codeman/settings.json` is one file for the whole server: the synced App Settings (feature switches, the plan-usage collection switch `showPlanUsageLimits`, `runMode`, `respawnPresets`, `tunnelEnabled`, ...), plus `modelConfig`, `caseOrder` and `lastUsedCase`. Reads are open to every signed-in user; in multi-user mode a non-admin reads it without what is the admin's (`voiceSettings.apiKey`, the Deepgram key an admin saved, and the admin's case names in `caseOrder` and `lastUsedCase`). **In multi-user mode every write is admin only:** for a non-admin the routes below answer `403 FORBIDDEN` before the body is even validated, so nothing is written, no watcher is toggled and no tunnel is started. Single-user mode is unaffected. Every write to the file (these routes, and the `lastUsedCase` that `POST /api/quick-start` and `POST /api/ralph-loop/start` record before they answer) runs one at a time and replaces the file atomically (mode `0600`); the one write outside that queue is a config-backup restore, which must work on a file that does not parse. A write to a `settings.json` that exists but is not a JSON object is refused rather than starting from `{}`, which would erase every other setting: the routes below answer `409 CONFLICT` with a message naming the fix and leave the file untouched, the two starts skip their `lastUsedCase`, and an admin's `POST /api/cases/:name/rename` answers the same `409 CONFLICT`, checked before it ends any session or moves the folder. A missing or empty file still starts from `{}`.
`~/.codeman/settings.json` is one file for the whole server: the synced App Settings (feature switches, the plan-usage collection switch `showPlanUsageLimits`, `runMode`, `respawnPresets`, `tunnelEnabled`, ...), plus `modelConfig`, `caseOrder`, `lastUsedCase`, `lastPickedCase` and `caseSettings`. Reads are open to every signed-in user; in multi-user mode a non-admin reads it without what is the admin's (`voiceSettings.apiKey`, the Deepgram key an admin saved, and the admin's case names in `caseOrder`, `lastUsedCase`, `lastPickedCase` and `caseSettings`). **In multi-user mode every write is admin only:** for a non-admin the routes below answer `403 FORBIDDEN` before the body is even validated, so nothing is written, no watcher is toggled and no tunnel is started. Single-user mode is unaffected. Every write to the file (these routes, and the `lastUsedCase` that `POST /api/quick-start` and `POST /api/ralph-loop/start` record before they answer) runs one at a time and replaces the file atomically (mode `0600`); the one write outside that queue is a config-backup restore, which must work on a file that does not parse. A write to a `settings.json` that exists but is not a JSON object is refused rather than starting from `{}`, which would erase every other setting: the routes below answer `409 CONFLICT` with a message naming the fix and leave the file untouched, the two starts skip their `lastUsedCase`, and an admin's `POST /api/cases/:name/rename` answers the same `409 CONFLICT`, checked before it ends any session or moves the folder. A missing or empty file still starts from `{}`.
| Method | Path | Body | Notes |
| ------ | ----------------------------- | ------------------------------------ | ----- |
| `GET` | `/api/settings` | none | The stored settings object. Never writes. For a non-admin in multi-user mode, without `voiceSettings.apiKey`, `caseOrder` and `lastUsedCase`. |
| `PUT` | `/api/settings` | partial settings (strict schema) | Merged into the stored file; services (watchers, tunnel, tmux history limit) reconcile to the merged result. `403 FORBIDDEN` for a non-admin in multi-user mode. A tunnel enable with no login (no `CODEMAN_PASSWORD`, not multi-user) is refused separately (`403 OPERATION_FAILED`, resend with `acknowledgeUnauthTunnel: true`) unless the server runs with `CODEMAN_ALLOW_UNAUTHENTICATED_TUNNEL=1` or `--allow-unauthenticated-tunnel`. `CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK` does not count; when it is set, the refusal says so. An enable without that authorization is accepted only while the tunnel is already running and never stores `tunnelEnabled: true`; if the tunnel stops before the request would start it, it is not restarted and the answer is the same `403 OPERATION_FAILED` (the request's other settings are saved). |
| `GET` | `/api/settings` | none | The stored settings object. Never writes. For a non-admin in multi-user mode, without `voiceSettings.apiKey`, `caseOrder`, `lastUsedCase`, `lastPickedCase` and `caseSettings`. |
| `PUT` | `/api/settings` | partial settings (strict schema) | Merged into the stored file; services (watchers, tunnel, tmux history limit) reconcile to the merged result. `caseSettings` (per-case run options, `{ [case]: { agentTeams, opusContext1m } }`) is merged per case inside the serialised writer, so devices saving different cases never overwrite each other; it is ignored while `syncCaseState` is not `true` and answers `400` past 500 cases. `403 FORBIDDEN` for a non-admin in multi-user mode. A tunnel enable with no login (no `CODEMAN_PASSWORD`, not multi-user) is refused separately (`403 OPERATION_FAILED`, resend with `acknowledgeUnauthTunnel: true`) unless the server runs with `CODEMAN_ALLOW_UNAUTHENTICATED_TUNNEL=1` or `--allow-unauthenticated-tunnel`. `CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK` does not count; when it is set, the refusal says so. An enable without that authorization is accepted only while the tunnel is already running and never stores `tunnelEnabled: true`; if the tunnel stops before the request would start it, it is not restarted and the answer is the same `403 OPERATION_FAILED` (the request's other settings are saved). |
| `GET` | `/api/tunnel/status` | none | `{ running, url, loginConfigured }`. `loginConfigured` is `true` when a login protects the server (`CODEMAN_PASSWORD` set or multi-user mode on), the same test the tunnel guard above applies; with `false` the browser asks before every tunnel enable. Neither opt-in variable makes it `true`. |
| `GET` | `/api/execution/model-config` | none | `modelConfig` (cron's and the plan orchestrator's default model). |
| `PUT` | `/api/execution/model-config` | `{ defaultModel?, agentTypeOverrides?, ... }` | Replaces `modelConfig`. `403 FORBIDDEN` for a non-admin in multi-user mode. |
@@ -1628,7 +1628,7 @@ A local case whose folder is the top of a git repository can keep **worktrees**
A non-admin's `POST /api/quick-start` and `POST /api/ralph-loop/start` work as before but do not record their case as `lastUsedCase` (a server-wide key).
`POST /api/cases/:name/rename` (body `{ newName, renameFolder?, stopSessions? }`, strict) renames a case. It is not one of the admin-only writes above: in multi-user mode a non-admin renames their own cases, and only the `settings.json` part is skipped for them. A case under the cases directory is its folder, so it always moves; a linked case renames its alias in `linked-cases.json` and moves its real folder (next to the old one, named `newName`) only with `renameFolder: true`. Remote and Docker cases are `400`. The new name must be free in every namespace the list merges (`409`). Live sessions whose working directory is at or inside the folder that would move make it `409` with `requiresStop: true` and `sessions: [{ id, name }]`; retry with `stopSessions: true` to end them first (they are closed as by `DELETE /api/sessions/:id`, nothing is restarted); another user's session is `403`. The folder is held by an in-process lock for the whole operation (an overlapping rename is `409 BUSY`, and `new Session` refuses a working directory inside it); a session that is still being created in the folder (constructed, not yet registered, so it can be neither listed nor stopped) makes a folder move `409` with `renameCode: "BUSY"` too, and a retry once it is registered lists it like any open session; the move is retried for a transient `EBUSY`/`EPERM`/`EACCES` and then answers `423` with `renameCode: "LOCKED"`, a cross-device move is `400`, and if the registries cannot be written afterwards `settings.json` and the folder are put back (when the folder cannot be moved back, the answer is `500` and says it is now at the new path). A failed write is `500`. A linked case's entry is checked again where it is written, after any folder move, and a refusal there puts everything back the same way: a new name linked since the first check is `409 ALREADY_EXISTS` with `renameCode: "EXISTS"`, an old entry unlinked or changed since then is `409 CONFLICT` with `renameCode: "BUSY"`, and a `linked-cases.json` that stopped being readable or a JSON object is `409 CONFLICT` with no `renameCode` (one already damaged when the rename starts reads as empty, so a linked name answers `404`). An error from the rename itself carries `stoppedSessions`: how many sessions it ended before it failed. On success the response is `{ oldName, newName, oldPath, newPath, folderMoved, stoppedSessions }`; `caseOrder` and `lastUsedCase` (in multi-user mode, by an admin's rename only), `linked-cases.json` and Claude Code's `projects/<path>` history are updated (history already at the new path is merged into, never overwritten), and `case:renamed` is broadcast. A linked case is admin only in multi-user mode: for a non-admin the name is looked up in their own case space only (`404` when they have no case of that name).
`POST /api/cases/:name/rename` (body `{ newName, renameFolder?, stopSessions? }`, strict) renames a case. It is not one of the admin-only writes above: in multi-user mode a non-admin renames their own cases, and only the `settings.json` part is skipped for them. A case under the cases directory is its folder, so it always moves; a linked case renames its alias in `linked-cases.json` and moves its real folder (next to the old one, named `newName`) only with `renameFolder: true`. Remote and Docker cases are `400`. The new name must be free in every namespace the list merges (`409`). Live sessions whose working directory is at or inside the folder that would move make it `409` with `requiresStop: true` and `sessions: [{ id, name }]`; retry with `stopSessions: true` to end them first (they are closed as by `DELETE /api/sessions/:id`, nothing is restarted); another user's session is `403`. The folder is held by an in-process lock for the whole operation (an overlapping rename is `409 BUSY`, and `new Session` refuses a working directory inside it); a session that is still being created in the folder (constructed, not yet registered, so it can be neither listed nor stopped) makes a folder move `409` with `renameCode: "BUSY"` too, and a retry once it is registered lists it like any open session; the move is retried for a transient `EBUSY`/`EPERM`/`EACCES` and then answers `423` with `renameCode: "LOCKED"`, a cross-device move is `400`, and if the registries cannot be written afterwards `settings.json` and the folder are put back (when the folder cannot be moved back, the answer is `500` and says it is now at the new path). A failed write is `500`. A linked case's entry is checked again where it is written, after any folder move, and a refusal there puts everything back the same way: a new name linked since the first check is `409 ALREADY_EXISTS` with `renameCode: "EXISTS"`, an old entry unlinked or changed since then is `409 CONFLICT` with `renameCode: "BUSY"`, and a `linked-cases.json` that stopped being readable or a JSON object is `409 CONFLICT` with no `renameCode` (one already damaged when the rename starts reads as empty, so a linked name answers `404`). An error from the rename itself carries `stoppedSessions`: how many sessions it ended before it failed. On success the response is `{ oldName, newName, oldPath, newPath, folderMoved, stoppedSessions }`; `caseOrder`, `lastUsedCase`, `lastPickedCase` and `caseSettings` (in multi-user mode, by an admin's rename only), `linked-cases.json` and Claude Code's `projects/<path>` history are updated (history already at the new path is merged into, never overwritten), and `case:renamed` is broadcast. A linked case is admin only in multi-user mode: for a non-admin the name is looked up in their own case space only (`404` when they have no case of that name).
### System operations
+1 -1
View File
@@ -884,7 +884,7 @@ Paths, agent descriptions and diff lines render via `textContent` only (browser
⚠️ **The tracked list is a fixed allowlist** (`CONFIG_BACKUP_FILES`), never "everything in the data dir", and restore writes only those names whatever a manifest lists. `state.json` is deliberately NOT tracked: it is rewritten on every session event, so it would make a snapshot per event and bury the real changes. It also holds cron jobs and respawn presets, which is the documented gap.
⚠️ **One snapshot per real change.** The change check compares a digest of the file set with the newest manifest's `hash`, looking through `VOLATILE_KEYS`, fields a tracked file rewrites on its own: `custom-model-hosts.json` `lastDiscoveredAt` (the discovery poll), `users.json` `lastLoginAt` (every Basic login), `settings.json` `lastUsedCase` (a session started in another case), `intents.json` `recentPrompts`/`updatedAt` (every captured prompt), `docker-cases.json` `lastClaudeSessionId` (a `/clear` in a Docker case) and `webviews.json` `lastOpenedAt` (opening a web tab). Each was found the same way: one tick-driven rewrite made a snapshot per tick, and the 20 kept copies covered about 95 minutes. A new self-rewritten field in a tracked file needs an entry there and a row in the `it.each` table of `test/config-backup.test.ts`. The snapshot still stores the bytes and restore verifies the raw digest; only the comparison looks through these keys (so Compare calls `intents.json` unchanged when only prompts differ, yet a restore drops the prompts captured since).
⚠️ **One snapshot per real change.** The change check compares a digest of the file set with the newest manifest's `hash`, looking through `VOLATILE_KEYS`, fields a tracked file rewrites on its own: `custom-model-hosts.json` `lastDiscoveredAt` (the discovery poll), `users.json` `lastLoginAt` (every Basic login), `settings.json` `lastUsedCase` (a session started in another case) and `lastPickedCase` (a pick in a case picker), `intents.json` `recentPrompts`/`updatedAt` (every captured prompt), `docker-cases.json` `lastClaudeSessionId` (a `/clear` in a Docker case) and `webviews.json` `lastOpenedAt` (opening a web tab). Each was found the same way: one tick-driven rewrite made a snapshot per tick, and the 20 kept copies covered about 95 minutes. A new self-rewritten field in a tracked file needs an entry there and a row in the `it.each` table of `test/config-backup.test.ts`. The snapshot still stores the bytes and restore verifies the raw digest; only the comparison looks through these keys (so Compare calls `intents.json` unchanged when only prompts differ, yet a restore drops the prompts captured since).
⚠️ **Retention deletes only what this module made, and never the newest snapshot.** A folder counts only with the name pattern AND a parseable manifest of this origin, so a custom folder that also holds other things is safe to point at. A snapshot is aged from its SUCCESSOR's `createdAt` (its state was live until the next one was made): aging it by its own date deleted the last good copy on the first change after a quiet month, which is exactly the bad write the feature exists for. The keep count floor is 2 (`CONFIG_BACKUP_MIN_KEEP_COUNT`), since 1 deletes the state before every change. A new snapshot is never dated earlier than the newest one (`createdAt + 1`): one written while the clock read ahead (dual boot, a VM resumed with a stale clock) stayed newest after the correction, differed from every later pass, and made each one write a copy until real time caught up. The flip side, a clock once set to a future year, is a Troubleshooting entry (delete the future-dated folder).
+1 -1
View File
@@ -161,7 +161,7 @@ Codeman now ships a global **Startup Mode** picker (App Settings, Claude CLI tab
| Screenshots `/api/screenshots` (deprecated) | Deprecated: removed in a later MAJOR, so no per-user subdir is planned; the replacement `POST /api/sessions/:id/paste-image` is already session-scoped. Former plan: per-user subdir `~/.codeman/screenshots/<username>/` in multi-user mode. Note: `GET /:name` deliberately rejects `/` in names as traversal, so derive the subdir server-side from `req.authUser` and keep client-visible names flat |
| Attachments | Already session-scoped; inherits the session owner check. `attachmentConfineToWorkspace` is a global, default-OFF setting today: in multi-user mode it is FORCED ON for non-admins regardless of the setting (their attachments must resolve inside their own space); the setting keeps meaning what it means for admins |
| File routes (browse/preview) | Path allowlist adds: non-admin paths must resolve (realpath) inside their own space or their own sessions' workingDirs |
| Settings (`settings.json`) | Global, admin-only writes in multi-user mode; reads allowed (per-device display keys stay in localStorage as today). **Implemented (1.42.0):** `PUT /api/settings`, `PUT /api/execution/model-config`, `PUT /api/config` and `PUT /api/cases/order` answer 403 `FORBIDDEN` to a non-admin before parsing anything; quick-start and Ralph loop start skip a non-admin's `lastUsedCase`; the browser skips those writes for a non-admin (`_settingsReadOnly()`, settings-ui.js) and keeps their own run mode and respawn presets on the device; a non-admin's `GET /api/settings` omits `voiceSettings.apiKey`, `caseOrder` and `lastUsedCase` (`settingsForNonAdmin`). Per-user server settings: out of scope v1|
| Settings (`settings.json`) | Global, admin-only writes in multi-user mode; reads allowed (per-device display keys stay in localStorage as today). **Implemented (1.42.0):** `PUT /api/settings`, `PUT /api/execution/model-config`, `PUT /api/config` and `PUT /api/cases/order` answer 403 `FORBIDDEN` to a non-admin before parsing anything; quick-start and Ralph loop start skip a non-admin's `lastUsedCase`; the browser skips those writes for a non-admin (`_settingsReadOnly()`, settings-ui.js) and keeps their own run mode and respawn presets on the device; a non-admin's `GET /api/settings` omits `voiceSettings.apiKey`, `caseOrder`, `lastUsedCase`, `lastPickedCase` and `caseSettings` (`settingsForNonAdmin`). Per-user server settings: out of scope v1|
| System ops (self-update, tunnel toggle, span-displays, docker image build) | Admin-only. **Implemented (1.42.0):** `POST /api/system/update`, `/api/system/span-displays`, `/api/tunnel/qr/regenerate` and `/api/cleanup-state` answer 403 `FORBIDDEN` to a non-admin; a non-admin's `POST /api/auth/revoke` revokes only their own login sessions and `GET /api/tunnel/info` lists only those |
| `getLightState` init snapshot | Filtered per connection. Actual contents to filter (verified): `sessions`, `scheduledRuns`, `respawnStatus`, `subagents`, `workflowRuns`, `planUsage` (host-plan telemetry: admin-only); `globalStats` stays coarse-global. Cron jobs are NOT in the snapshot (they have their own REST route; filter there). The snapshot is cached process-wide (`LIGHT_STATE_CACHE_TTL_MS`): either key the cache per role/user or filter AFTER the cache on each send |
+1 -1
View File
@@ -41,7 +41,7 @@ cron jobs.
A field a file rewrites on its own is not a change, and the comparison looks through it (the
snapshot still stores the file exactly as written, and a restore puts those fields back as they
were): `custom-model-hosts.json` `lastDiscoveredAt` (the model-discovery poll, every five
minutes), `users.json` `lastLoginAt`, `settings.json` `lastUsedCase`, `intents.json`
minutes), `users.json` `lastLoginAt`, `settings.json` `lastUsedCase` and `lastPickedCase`, `intents.json`
`recentPrompts` and `updatedAt` (every captured prompt) and `docker-cases.json`
`lastClaudeSessionId` (a Docker case's conversation id after a `/clear`) and `webviews.json`
`lastOpenedAt` (opening a web tab). Without that, every
+1 -1
View File
@@ -258,7 +258,7 @@ Rebinding for the shortcut registry. See [Keyboard Shortcuts](Keyboard-Shortcuts
### System
`CLAUDE.md` template for new cases, default working directory, the image watcher, the prompt-cache keep-warm default (**Keep prompt caches warm**, off) and its cap (**Keep warm for at most**, 2 hours after a session's last prompt; see [Keeping Agents Running](Keeping-Agents-Running)), and
Cloudflare tunnel controls including the tunnel URL. The **Diagnostics** group runs
Cloudflare tunnel controls including the tunnel URL. **Sync cases across devices** (under Paths, off by default): 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 reads, writes nor follows them. Each save sends only that case and the server merges it, so devices saving different cases never overwrite each other; a case renamed in Codeman keeps its options, and deleting or unlinking it removes them. A save the server refuses leaves the choice on that device only until the next sync, which brings the server's copy back, and says so. Only a case picked in a case picker is followed, not one a quick-start or an agent's spawned worker used. If another device turns this off, this one stops following, and the server ignores case options sent while it is off. Turning it off leaves the stored options in `settings.json`, so turning it back on brings them back. A device that already has options for a case keeps them until a device changes that case's options while this is on. The **Diagnostics** group runs
`codeman doctor` on the server and lists the agent CLIs, tmux, Node and the optional office
tools with their versions and install hints (admin only in multi-user mode). In multi-user
mode, the **Users** administration entry is injected here.
+1 -1
View File
@@ -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'],
+48
View File
@@ -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;
}
+5
View File
@@ -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} 正在终端中等待你的回答`],
+7
View File
@@ -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>
+131 -9
View File
@@ -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() {
+4 -1
View File
@@ -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) {
+31 -4
View File
@@ -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,
+23 -4
View File
@@ -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);
}
});
+12
View File
@@ -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(),
+5 -1
View File
@@ -173,7 +173,11 @@ describe('createConfigBackup', () => {
'hostId',
],
['users.json', (n: number) => JSON.stringify({ users: [{ name: 'a', role: 'admin', lastLoginAt: n }] }), 'role'],
['settings.json', (n: number) => JSON.stringify({ displayName: 'x', lastUsedCase: `case-${n}` }), 'displayName'],
[
'settings.json',
(n: number) => JSON.stringify({ displayName: 'x', lastUsedCase: `case-${n}`, lastPickedCase: `case-${n}` }),
'displayName',
],
[
'intents.json',
(n: number) => JSON.stringify({ profiles: [{ key: 'k', goals: 'g', updatedAt: n, recentPrompts: [`p${n}`] }] }),
+34 -7
View File
@@ -302,7 +302,13 @@ describe('POST /api/cases/:name/rename (a linked case)', () => {
const real = join(work, 'my-project');
mkdirSync(real);
writeFileSync(LINKED(), JSON.stringify({ proj: real }));
writeFileSync(SETTINGS_PATH, JSON.stringify({ caseOrder: ['a', 'proj', 'b'], lastUsedCase: 'proj' }));
const seeded = {
caseOrder: ['a', 'proj', 'b'],
lastUsedCase: 'proj',
lastPickedCase: 'proj',
caseSettings: { proj: { agentTeams: true, opusContext1m: false } },
};
writeFileSync(SETTINGS_PATH, JSON.stringify(seeded));
// the registry reads fine but cannot be written: the rename must undo the folder move
const spy = failRegistryWrite();
let res;
@@ -316,7 +322,7 @@ describe('POST /api/cases/:name/rename (a linked case)', () => {
expect(linked()).toEqual({ proj: real });
expect(existsSync(join(work, 'renamed'))).toBe(false);
// settings.json is written first, so it must be put back too
expect(settings()).toEqual({ caseOrder: ['a', 'proj', 'b'], lastUsedCase: 'proj' });
expect(settings()).toEqual(seeded);
});
it('puts back only the keys it wrote, keeping a settings write made in between', async () => {
@@ -324,17 +330,38 @@ describe('POST /api/cases/:name/rename (a linked case)', () => {
const real = join(work, 'my-project');
mkdirSync(real);
writeFileSync(LINKED(), JSON.stringify({ proj: real }));
writeFileSync(SETTINGS_PATH, JSON.stringify({ caseOrder: ['a', 'proj', 'b'], lastUsedCase: 'proj' }));
// Another writer (a settings save) lands after the rename wrote settings.json and before the
// registry write fails.
const spy = failRegistryWrite(() => mutateSettingsFile((current) => ({ ...current, agentSkillEnabled: true })));
const entry = { agentTeams: true, opusContext1m: false };
writeFileSync(
SETTINGS_PATH,
JSON.stringify({
caseOrder: ['a', 'proj', 'b'],
lastUsedCase: 'proj',
lastPickedCase: 'proj',
caseSettings: { proj: entry },
})
);
// Another writer (a settings save, and a per-case save from another device) lands after the rename wrote
// settings.json and before the registry write fails.
const spy = failRegistryWrite(() =>
mutateSettingsFile((current) => ({
...current,
agentSkillEnabled: true,
caseSettings: { ...(current.caseSettings as object), other: { agentTeams: false, opusContext1m: true } },
}))
);
try {
const res = await rename(app, 'proj', { newName: 'renamed', renameFolder: true });
expect(res.statusCode).toBe(500);
} finally {
spy.mockRestore();
}
expect(settings()).toEqual({ caseOrder: ['a', 'proj', 'b'], lastUsedCase: 'proj', agentSkillEnabled: true });
expect(settings()).toEqual({
caseOrder: ['a', 'proj', 'b'],
lastUsedCase: 'proj',
lastPickedCase: 'proj',
caseSettings: { proj: entry, other: { agentTeams: false, opusContext1m: true } },
agentSkillEnabled: true,
});
});
describe('when the registry changes after the first check', () => {
+4
View File
@@ -526,6 +526,10 @@ describe('the new toasts in zh-CN', () => {
'Saved on this device, not on the server: the server could not be reached',
'已保存到本设备,但未保存到服务器:无法连接到服务器',
],
[
'Options for "demo" (until the next sync) saved on this device, not on the server: HTTP 500',
'案例“demo”的选项已保存到本设备(同步前有效),但未保存到服务器:HTTP 500',
],
[`Tunnel not stopped: ${TORN}`, `隧道未停止:${TORN}`],
];
for (const [english, chinese] of cases) {
+138
View File
@@ -0,0 +1,138 @@
/**
* @fileoverview Opt-in case sync, server side (live server, ephemeral port, real settings.json): the shared
* per-case options merge inside the serialised settings writer, a non-admin never reads them, and a case
* rename or delete carries them along (CASE-SYNC-001, CASE-SYNC-002).
*/
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { WebServer } from '../src/web/server.js';
import { TmuxManager } from '../src/tmux-manager.js';
import { SETTINGS_PATH } from '../src/web/route-helpers.js';
import { getCasesDir } from '../src/config/cases-dir.js';
import { settingsForNonAdmin } from '../src/web/routes/system-routes.js';
import {
dropCaseSettings,
MAX_SHARED_CASES,
mergeCaseSettings,
renameCaseSettings,
} from '../src/web/case-state-sync.js';
vi.spyOn(TmuxManager, 'isTmuxAvailable').mockReturnValue(true);
const on = { agentTeams: true, opusContext1m: false };
const off = { agentTeams: false, opusContext1m: true };
let server: WebServer;
const url = (p: string) => `http://localhost:${server.boundPort}${p}`;
const seed = (value: unknown) => writeFileSync(SETTINGS_PATH, JSON.stringify(value, null, 2));
const readSettings = () => JSON.parse(readFileSync(SETTINGS_PATH, 'utf-8')) as Record<string, any>;
async function send(method: string, path: string, body?: unknown) {
const res = await fetch(url(path), {
method,
headers: body === undefined ? {} : { 'Content-Type': 'application/json' },
body: body === undefined ? undefined : JSON.stringify(body),
});
return { status: res.status, body: (await res.json()) as Record<string, any> };
}
let n = 0;
const caseName = () => `case-sync-${process.pid}-${++n}`;
beforeAll(async () => {
mkdirSync(join(SETTINGS_PATH, '..'), { recursive: true });
seed({});
server = new WebServer(0, false, true);
await server.start();
}, 30000);
afterAll(async () => {
await server?.stop();
vi.restoreAllMocks();
}, 30000);
describe('shared case options', () => {
beforeEach(() => seed({ syncCaseState: true }));
it('merges concurrent saves of different cases instead of letting the second discard the first', async () => {
const [a, b, c] = await Promise.all([
send('PUT', '/api/settings', { caseSettings: { alpha: on } }),
send('PUT', '/api/settings', { caseSettings: { beta: off } }),
send('PUT', '/api/settings', { caseSettings: { gamma: on } }),
]);
expect([a.status, b.status, c.status]).toEqual([200, 200, 200]);
expect(readSettings().caseSettings).toEqual({ alpha: on, beta: off, gamma: on });
});
it('ignores a caseSettings PUT while syncCaseState is off, and takes one that turns it on', async () => {
seed({ syncCaseState: false, caseSettings: { kept: off } });
expect((await send('PUT', '/api/settings', { caseSettings: { alpha: on } })).status).toBe(200);
expect(readSettings().caseSettings).toEqual({ kept: off });
seed({});
await send('PUT', '/api/settings', { caseSettings: { alpha: on } });
expect(readSettings().caseSettings).toBeUndefined();
await send('PUT', '/api/settings', { syncCaseState: true, caseSettings: { alpha: on } });
expect(readSettings().caseSettings).toEqual({ alpha: on });
});
it('keeps the other cases when one is saved again, and every other setting', async () => {
seed({ syncCaseState: true, lastUsedCase: 'x', caseSettings: { alpha: on, beta: off } });
await send('PUT', '/api/settings', { caseSettings: { alpha: off } });
expect(readSettings()).toEqual({ syncCaseState: true, lastUsedCase: 'x', caseSettings: { alpha: off, beta: off } });
});
it('refuses a merge that takes the file past the limit and leaves it as it was', async () => {
const full = Object.fromEntries(Array.from({ length: MAX_SHARED_CASES }, (_, i) => [`c${i}`, on]));
seed({ syncCaseState: true, caseSettings: full });
const res = await send('PUT', '/api/settings', { caseSettings: { overflow: on } });
expect(res.status).toBe(400);
expect(Object.keys(readSettings().caseSettings)).toHaveLength(MAX_SHARED_CASES);
expect((await send('PUT', '/api/settings', { caseSettings: { c1: off } })).status).toBe(200);
});
it('moves the options with a renamed case, and drops them when the case is deleted', async () => {
const oldName = caseName();
const newName = caseName();
mkdirSync(join(getCasesDir(), oldName), { recursive: true });
seed({
syncCaseState: true,
caseOrder: [oldName],
lastPickedCase: oldName,
caseSettings: { [oldName]: on, keep: off },
});
const renamed = await send('POST', `/api/cases/${oldName}/rename`, { newName });
expect(renamed.body.success).toBe(true);
expect(readSettings().caseSettings).toEqual({ [newName]: on, keep: off });
expect(readSettings().lastPickedCase).toBe(newName);
const deleted = await send('DELETE', `/api/cases/${newName}`);
expect(deleted.body.success).toBe(true);
expect(readSettings().caseSettings).toEqual({ keep: off });
});
});
describe('what a non-admin is given', () => {
it('has the shared map removed, and keeps the switch the page reads', () => {
const out = settingsForNonAdmin({
syncCaseState: true,
caseOrder: ['a'],
lastUsedCase: 'a',
lastPickedCase: 'a',
caseSettings: { a: on },
agentSkillEnabled: true,
});
expect(out).toEqual({ syncCaseState: true, agentSkillEnabled: true });
});
});
describe('case map helpers', () => {
it('merge, rename and drop leave the input alone and report nothing to do as null', () => {
const map = { a: on, b: off };
expect(mergeCaseSettings(map, { c: on })).toEqual({ a: on, b: off, c: on });
expect(renameCaseSettings(map, 'a', 'z')).toEqual({ b: off, z: on });
expect(renameCaseSettings(map, 'nope', 'z')).toBeNull();
expect(dropCaseSettings(map, 'a')).toEqual({ b: off });
expect(dropCaseSettings(map, 'nope')).toBeNull();
expect(mergeCaseSettings(undefined, { a: on })).toEqual({ a: on });
expect(map).toEqual({ a: on, b: off });
});
});
+87
View File
@@ -0,0 +1,87 @@
/**
* @fileoverview Case sync across two devices, end to end: a real server on an ephemeral port (isolated test
* HOME) and two separate Chromium contexts (each with its own localStorage, like two devices). Saves of
* different cases from both contexts both land, the other context takes them on returning to its tab, and
* once the server says sync is off the selected case stops following.
*/
import { mkdirSync } from 'node:fs';
import { join } from 'node:path';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { chromium, type Browser, type Page } from 'playwright';
import { WebServer } from '../src/web/server.js';
import { getCasesDir } from '../src/config/cases-dir.js';
describe('Sync cases across devices in two browsers', () => {
let server: WebServer;
let browser: Browser;
let a: Page;
let b: Page;
const one = `sync-a-${process.pid}`;
const two = `sync-b-${process.pid}`;
const base = () => `http://localhost:${server.boundPort}`;
const put = (body: unknown) =>
fetch(`${base()}/api/settings`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
const serverSettings = async () => ((await (await fetch(`${base()}/api/settings`)).json()) as any).data;
const open = async () => {
const page = await (await browser.newContext({ viewport: { width: 1400, height: 900 } })).newPage();
await page.goto(base(), { waitUntil: 'domcontentloaded' });
await page.waitForFunction(() => (window as any).app?.terminal, null, { timeout: 30000 });
return page;
};
const opts = (page: Page, name: string) => page.evaluate((n) => (window as any).app.getCaseSettings(n), name);
const returnToTab = (page: Page) =>
page.evaluate(async () => {
const app = (window as any).app;
await app._syncCaseStateFromServer();
});
beforeAll(async () => {
for (const name of [one, two]) mkdirSync(join(getCasesDir(), name), { recursive: true });
server = new WebServer(0, false, true);
await server.start();
browser = await chromium.launch({ headless: true });
await put({ syncCaseState: true });
a = await open();
b = await open();
for (const page of [a, b]) {
await page.evaluate(async () => {
const app = (window as any).app;
await app.loadAppSettingsFromServer();
});
}
}, 60000);
afterAll(async () => {
await browser?.close();
await server?.stop();
}, 60000);
it("keeps both devices' saves and lets the other device take them", async () => {
await Promise.all([
a.evaluate((n) => (window as any).app.saveCaseSettings(n, { agentTeams: true, opusContext1m: false }), one),
b.evaluate((n) => (window as any).app.saveCaseSettings(n, { agentTeams: false, opusContext1m: false }), two),
]);
await expect
.poll(async () => Object.keys((await serverSettings()).caseSettings ?? {}).sort(), { timeout: 10000 })
.toEqual([one, two].sort());
await returnToTab(b);
await returnToTab(a);
expect(await opts(b, one)).toEqual({ agentTeams: true, opusContext1m: false });
expect(await opts(a, two)).toEqual({ agentTeams: false, opusContext1m: false });
});
it('follows the selected case while sync is on, and stops once the server says it is off', async () => {
await put({ lastPickedCase: two });
await returnToTab(a);
expect(await a.evaluate(() => (document.getElementById('quickStartCase') as HTMLSelectElement).value)).toBe(two);
await put({ syncCaseState: false, lastPickedCase: one });
await returnToTab(a);
expect(await a.evaluate(() => (document.getElementById('quickStartCase') as HTMLSelectElement).value)).toBe(two);
expect(await a.evaluate(() => (window as any).app.loadAppSettingsFromStorage().syncCaseState)).toBe(false);
});
});
+331
View File
@@ -0,0 +1,331 @@
/**
* @fileoverview Opt-in case sync, client side: the schema accepts the setting and a per-case map, and the
* session-ui helpers only touch the server when the setting is on, send one case at a time, report a failed
* save, and obey the server's answer about whether sync 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');
// The real write-result handling the case sync reuses (a toast for a failed save, read-only after a 403).
const SETTINGS_SRC = readFileSync(new URL('../src/web/public/settings-ui.js', import.meta.url), 'utf-8');
type Reply = { status?: number; body?: unknown } | 'throw';
type Handler = (url: string, init?: { method?: string; body?: string }) => Reply | Promise<Reply>;
function makeApp(settings: Record<string, unknown>, onFetch: Handler = () => ({ body: { success: true, data: {} } })) {
const store = new Map<string, string>();
const calls: { method: string; body: unknown }[] = [];
const toasts: string[] = [];
const select = { value: 'alpha' };
const hidden = new Set<string>();
const popover = (id: string) => ({ classList: { add: (c: string) => c === 'hidden' && void hidden.add(id) } });
function CodemanApp(this: unknown) {}
const ctx = vm.createContext({
CodemanApp,
window: {},
document: {
getElementById: (id: string) => (id.startsWith('caseSettingsPopover') ? popover(id) : select),
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 }) => {
calls.push({ method: init?.method ?? 'GET', body: init?.body ? JSON.parse(init.body) : undefined });
const r = await onFetch(url, init);
if (r === 'throw') throw new Error('offline');
const status = r.status ?? 200;
const res = { ok: status < 400, status, json: async () => r.body ?? {}, clone: () => res };
return res;
},
console,
});
vm.runInContext(SRC, ctx);
vm.runInContext(SETTINGS_SRC, ctx);
const app = Object.create((CodemanApp as unknown as { prototype: object }).prototype) as Record<string, any>;
app.loadAppSettingsFromStorage = () => settings;
app.saveAppSettingsToStorage = (s: Record<string, unknown>) => Object.assign(settings, s);
app.showToast = (m: string) => void toasts.push(m);
app.cases = [{ name: 'alpha' }, { name: 'beta' }];
app.updateDirDisplayForCase = () => {};
app.updateMobileCaseLabel = () => {};
app.updateCasePickerInput = () => {};
app.closeCasePicker = () => {};
return { app, store, calls, toasts, select, settings, hidden };
}
const settle = () => new Promise((r) => setTimeout(r, 0));
const entry = { agentTeams: true, opusContext1m: false };
describe('syncCaseState schema', () => {
it('accepts the setting with a per-case map, and rejects junk', () => {
expect(SettingsUpdateSchema.safeParse({ syncCaseState: true, caseSettings: { demo: entry } }).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);
});
});
describe('saving a case', () => {
it('stays on this browser while the setting is off', async () => {
const { app, store, calls } = makeApp({});
app.saveCaseSettings('demo', entry);
await settle();
expect(store.get('caseSettings_demo')).toContain('agentTeams');
expect(calls).toEqual([]);
});
it('sends only that case (no read of the server map first), and nothing when read-only', async () => {
const on = makeApp({ syncCaseState: true });
on.app.saveCaseSettings('demo', entry);
await settle();
expect(on.calls).toEqual([{ method: 'PUT', body: { caseSettings: { demo: entry } } }]);
const ro = makeApp({ syncCaseState: true });
ro.app._settingsReadOnly = () => true;
ro.app.saveCaseSettings('demo', entry);
await settle();
expect(ro.calls).toEqual([]);
});
it('sends rapid saves one after another, in the order they were made', async () => {
let release!: () => void;
const first = new Promise<void>((r) => (release = r));
let n = 0;
const { app, calls } = makeApp({ syncCaseState: true }, async () => {
if (++n === 1) await first;
return { body: { success: true, data: {} } };
});
app.saveCaseSettings('demo', { agentTeams: true, opusContext1m: true });
app.saveCaseSettings('demo', { agentTeams: false, opusContext1m: false });
await settle();
expect(calls).toHaveLength(1); // the second waits for the first
release();
await app._caseSyncChain;
expect(calls.map((c) => (c.body as any).caseSettings.demo.agentTeams)).toEqual([true, false]);
});
it.each([
['an HTTP 500', { status: 500, body: { success: false, error: 'boom' } }],
['a dropped connection', 'throw' as const],
])('keeps the local choice and says it was not synced after %s', async (_label, reply) => {
const { app, store, toasts } = makeApp({ syncCaseState: true }, () => reply as Reply);
app.saveCaseSettings('demo', entry);
await app._caseSyncChain;
expect(JSON.parse(store.get('caseSettings_demo')!)).toEqual(entry);
expect(toasts).toHaveLength(1);
expect(toasts[0]).toContain('not on the server');
expect(toasts[0]).toContain('until the next sync');
});
it('hands an admin-only refusal to the read-only handling, without a toast', async () => {
const forbidden = { status: 403, body: { success: false, errorCode: 'FORBIDDEN' } };
const { app, toasts } = makeApp({ syncCaseState: true }, () => forbidden);
app._noteSettingsWriteRefusal = async (res: { status: number }) => {
if (res.status !== 403) return false;
app._settingsWriteForbidden = true;
return true;
};
app._settingsReadOnly = () => app._settingsWriteForbidden === true;
app.saveCaseSettings('demo', entry);
await app._caseSyncChain;
expect(toasts).toEqual([]);
expect(app._caseStateSyncOn()).toBe(false); // nothing more is sent from this page
});
});
describe('recording the picked case', () => {
it('writes lastPickedCase with lastUsedCase only while sync is on', async () => {
const on = makeApp({ syncCaseState: true });
await on.app.saveLastUsedCase('beta');
expect(on.calls[0].body).toEqual({ lastUsedCase: 'beta', lastPickedCase: 'beta' });
const off = makeApp({});
await off.app.saveLastUsedCase('beta');
expect(off.calls[0].body).toEqual({ lastUsedCase: 'beta' });
});
});
describe('a pick that did not reach the server', () => {
it('is not undone by the next tab return, and following resumes once a pick is stored', async () => {
let putOk = false;
const { app, select } = makeApp({ syncCaseState: true }, (_url, init) =>
init?.method === 'PUT'
? putOk
? { body: { success: true } }
: { status: 500, body: { error: 'x' } }
: { body: { success: true, data: { syncCaseState: true, lastPickedCase: 'alpha' } } }
);
select.value = 'beta';
await app.saveLastUsedCase('beta'); // the pick fails
await app._syncCaseStateFromServer(); // the server still says alpha
expect(select.value).toBe('beta');
putOk = true;
await app.saveLastUsedCase('beta');
await app._syncCaseStateFromServer();
expect(select.value).toBe('alpha');
});
it('lands before the copy a tab return reads', async () => {
const order: string[] = [];
const { app } = makeApp({ syncCaseState: true }, async (_url, init) => {
order.push(init?.method ?? 'GET');
return { body: { success: true, data: { syncCaseState: true } } };
});
void app.saveLastUsedCase('beta');
await app._syncCaseStateFromServer();
expect(order).toEqual(['PUT', 'GET']);
});
});
describe('taking the server copy', () => {
const server = { syncCaseState: true, caseSettings: { demo: entry } };
it('writes the options into the local keys only while the server says sync is on', () => {
const a = makeApp({});
a.app._applyServerCaseSettings(server);
expect(JSON.parse(a.store.get('caseSettings_demo')!)).toEqual(entry);
const b = makeApp({});
b.app._applyServerCaseSettings({ ...server, syncCaseState: false });
expect(b.store.size).toBe(0);
});
it('never applies a map to a read-only identity, even one the server handed over', () => {
const { app, store } = makeApp({ syncCaseState: true });
app._settingsReadOnly = () => true;
app._applyServerCaseSettings(server);
expect(store.size).toBe(0);
});
it('leaves a case alone while this device is still saving it', async () => {
let release!: () => void;
const hold = new Promise<void>((r) => (release = r));
const { app, store } = makeApp({ syncCaseState: true }, async () => {
await hold;
return { body: { success: true, data: {} } };
});
app.saveCaseSettings('demo', { agentTeams: false, opusContext1m: false });
app._applyServerCaseSettings({ syncCaseState: true, caseSettings: { demo: entry, other: entry } });
expect(JSON.parse(store.get('caseSettings_demo')!)).toEqual({ agentTeams: false, opusContext1m: false });
expect(JSON.parse(store.get('caseSettings_other')!)).toEqual(entry);
release();
await app._caseSyncChain;
});
});
describe('returning to the tab', () => {
const env = (data: Record<string, unknown>) => ({ body: { success: true, data } });
it('follows the selected case and the options while sync is on', async () => {
const { app, select, store } = makeApp({ syncCaseState: true }, () =>
env({ syncCaseState: true, lastPickedCase: 'beta', caseSettings: { beta: entry } })
);
await app._syncCaseStateFromServer();
expect(select.value).toBe('beta');
expect(JSON.parse(store.get('caseSettings_beta')!)).toEqual(entry);
});
it('stops following when another device turned sync off, and the cached switch follows the server', async () => {
const { app, select, store, settings } = makeApp({ syncCaseState: true }, () =>
env({ syncCaseState: false, lastPickedCase: 'beta', caseSettings: { beta: entry } })
);
await app._syncCaseStateFromServer();
expect(select.value).toBe('alpha');
expect(store.size).toBe(0);
expect(settings.syncCaseState).toBe(false);
expect(app._caseStateSyncOn()).toBe(false);
});
it('does not follow a case a quick-start or an agent started, only one picked in a picker', async () => {
const { app, select } = makeApp({ syncCaseState: true }, () =>
env({ syncCaseState: true, lastUsedCase: 'beta', lastPickedCase: 'alpha' })
);
await app._syncCaseStateFromServer();
expect(select.value).toBe('alpha');
});
it('closes an open case-options popover when the follow or an import changes what it shows', async () => {
const followed = makeApp({ syncCaseState: true }, () => env({ syncCaseState: true, lastPickedCase: 'beta' }));
await followed.app._syncCaseStateFromServer();
expect([...followed.hidden].sort()).toEqual(['caseSettingsPopover', 'caseSettingsPopoverMobile']);
const imported = makeApp({ syncCaseState: true }, () =>
env({ syncCaseState: true, caseSettings: { alpha: entry } })
);
await imported.app._syncCaseStateFromServer();
expect(imported.hidden.size).toBe(2);
const unchanged = makeApp({ syncCaseState: true }, () => env({ syncCaseState: true, lastPickedCase: 'alpha' }));
await unchanged.app._syncCaseStateFromServer();
expect(unchanged.hidden.size).toBe(0);
});
it('does not undo a pick or a case save made while its read was out', async () => {
let release!: () => void;
const gate = new Promise<void>((r) => (release = r));
const { app, select, store } = makeApp({ syncCaseState: true }, async (_url, init) => {
if (init?.method === 'PUT') return { body: { success: true, data: {} } };
await gate; // the server read this copy before the pick and the save below reached it
return env({
syncCaseState: true,
lastPickedCase: 'alpha',
caseSettings: { beta: { agentTeams: false, opusContext1m: true } },
});
});
const refresh = app._syncCaseStateFromServer();
await settle();
select.value = 'beta';
await app.saveLastUsedCase('beta');
app.saveCaseSettings('beta', entry);
await app._caseSyncChain;
release();
await refresh;
expect(select.value).toBe('beta');
expect(JSON.parse(store.get('caseSettings_beta')!)).toEqual(entry);
});
it('asks for nothing on a tab return while this device has sync off', async () => {
const { app, select, calls } = makeApp({}, () => env({ syncCaseState: true, lastUsedCase: 'beta' }));
await app._syncCaseStateFromServer();
expect(calls).toEqual([]);
expect(select.value).toBe('alpha');
});
it('does nothing for a read-only identity', async () => {
const { app, calls } = makeApp({ syncCaseState: true });
app._settingsReadOnly = () => true;
await app._syncCaseStateFromServer();
expect(calls).toEqual([]);
});
it('keeps what it has when the request fails', async () => {
const { app, select } = makeApp({ syncCaseState: true }, () => ({ status: 500, body: { error: 'x' } }));
await app._syncCaseStateFromServer();
expect(select.value).toBe('alpha');
});
it('discards an older refresh that answers after a newer one', async () => {
const releases: (() => void)[] = [];
const answers = [
env({ syncCaseState: true, lastPickedCase: 'beta' }), // the older request
env({ syncCaseState: true, lastPickedCase: 'alpha' }), // the newer request
];
let i = 0;
const { app, select } = makeApp({ syncCaseState: true }, async () => {
const mine = answers[i++];
await new Promise<void>((r) => releases.push(r));
return mine;
});
const older = app._syncCaseStateFromServer();
await settle();
const newer = app._syncCaseStateFromServer();
await settle();
releases[1](); // the newer answers first
await newer;
releases[0](); // then the older one arrives late
await older;
expect(select.value).toBe('alpha');
});
});