mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-11 01:39:34 +01:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7bdef0d86a | ||
|
|
2f17b8067a | ||
|
|
70dc42b248 |
No files matched your search
@@ -10,7 +10,7 @@
|
||||
"name": "codeman",
|
||||
"source": "./plugins/codeman",
|
||||
"description": "Drive Codeman from inside a Claude Code session: spawn worker sessions, prompt them, wait for them, read their answers, clean up. Acts inside a Codeman-managed session; outside one it only reads saved Codex conversations offline.",
|
||||
"version": "1.50.1",
|
||||
"version": "1.50.0",
|
||||
"author": {
|
||||
"name": "Ark0N",
|
||||
"url": "https://github.com/Ark0N"
|
||||
|
||||
@@ -1,40 +1,5 @@
|
||||
# aicodeman
|
||||
|
||||
## 1.50.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- e06257c: ### Thanks
|
||||
- @opticon454 for syncing case options and the picked case across devices, over five review rounds (#732).
|
||||
- @ludovicfourrage for dictation that survives a thinking pause and the Android dictation fixes, over three review rounds (#705).
|
||||
- @johoja12 for the log viewer reconnect fix (#720).
|
||||
- @shenlvkang-collab for the image zoom (#608) and the Files panel delete button (#529), both carried forward in #791 and #792, and for the foldable phone report (#702).
|
||||
|
||||
**A MacBook keeps working with the lid closed (#795).** The Codeman app for Mac has a new menu, Codeman > Keep This Mac Awake: Off, While Agents Work (and 5 minutes after the last one stops) or Always. It only acts for a Codeman running on this Mac, and pauses on battery unless you untick Only When Plugged In. On a MacBook, With the Lid Closed… keeps a closed Mac awake on its power adapter. It asks for your administrator password once and installs a small helper; unticking it removes the helper. Behaviour change: while it keeps watch for a Codeman on this Mac, closing the Codeman window hides the app instead of quitting it, so the Mac stays awake; click the Dock icon to bring it back, and quit with Cmd+Q (#800). With no server open, or one on another machine, closing the window still quits.
|
||||
|
||||
**The lid logo breathes while agents work (#794).** The Apple logo blink from 1.50.0 now has one switch, View > Blink Apple Logo. It breathes while agents work, blinks for a yellow tab and flashes for a red one: the more it moves, the more it needs you. It now moves on the logo's own hardware fades, so a blink no longer writes a system setting. Styles you picked in 1.50.0 are kept.
|
||||
|
||||
**The logo keeps blinking with the lid closed (#801).** While the Mac stays awake behind a closed lid on its power adapter (Keep This Mac Awake > With the Lid Closed…, or an external display), the logo goes on showing your agents, and the keyboard stays dark. macOS keeps the logo dark under a closed lid, so the app tells the logo the lid is open, the way LogoLight 1.3.1 does. A line under View > Apple Logo Blink Styles says what the closed lid did last: blinking, or why not. On battery nothing changes: the Mac sleeps when you close it.
|
||||
|
||||
**Codeman and LogoLight take turns on the logo (#802).** With LogoLight 1.3 or later running, LogoLight steps aside while Codeman shows its agents on the logo, and takes the logo back afterwards. Until now Codeman left the logo alone whenever LogoLight ran. An older LogoLight still keeps the logo, and Codeman tells you to update it when you turn the switch on.
|
||||
|
||||
**Dictation survives a thinking pause (#705).** A new Voice setting, Stop after silence, sets how long a pause may last before dictation stops: the provider's default, 3, 6, 15 or 30 seconds, or Manual. Claude dictation keeps going from one sentence to the next until you press Stop, and keeps your last words. On Chrome for Android, words are no longer repeated or lost at the end. Behaviour change: on iOS Safari dictation no longer stops after 750 ms without new words; it stops at the silence deadline like everywhere else.
|
||||
|
||||
**Sync cases across devices (#732).** A new setting, off by default, keeps each case's Agent Teams and Opus 1M options on the server instead of in each browser. When you come back to a tab, the case picker follows the case you last picked on another device.
|
||||
|
||||
**Delete a file from the Files panel (#792).** File rows in the Files panel, tree and search results, have a delete button that asks first. Symbolic links, `.git`, sensitive files and remote or adopted sessions are refused.
|
||||
|
||||
**Zoom the path picker's image preview (#791).** Pinch and drag on a phone, the mouse wheel or a trackpad on a desktop, double-click for 2x, or the zoom buttons. A small image now shows at its own size instead of stretched.
|
||||
|
||||
**Fixes.**
|
||||
- **The phone overview appears on a fold (#790).** Folding a phone, or narrowing a window below 600px, left the welcome screen up when the page had loaded wide. Refs #702.
|
||||
- **The log viewer stops reconnecting after a startup error (#720).** A refused path, a missing or too large file, the stream limit or a failed tail made the floating log viewer reconnect every few seconds.
|
||||
- **The local model install view no longer flashes Paused at the end.** A view read while the install job finished could show the job as paused.
|
||||
|
||||
**Fixes applied while landing.** #720 keeps the browser's own retry after a network error before the stream connects. The Mac app's window-close change (#800) came from checking #795 against how people quit the app. #705: the green Send button now stays up for the words saved when dictation is cut off by a reconnect, and the API reference says what a voice transcript frame holds.
|
||||
|
||||
**Docs and tests.** Both READMEs have a Download for Mac button at the top. The Mac app's code was reorganised into smaller modules (#789) with no change in behaviour.
|
||||
|
||||
## 1.50.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
@@ -52,7 +52,7 @@ Uses [Semantic Versioning](https://semver.org/) (`MAJOR.MINOR.PATCH`) via `@chan
|
||||
|
||||
When the user says **COM** (`COM` = patch, `COM minor`, `COM major`): **Read [`docs/releasing.md`](docs/releasing.md) and follow every step in order**; never improvise a release from memory. It covers the changeset, `npm run version-packages`, the `**Version**` line below, the branch check and explicit-path commit, deploy, the getcodeman.com badge, watching BOTH CI workflows, the MCP registry publish, and the Discussions announcement.
|
||||
|
||||
**Version**: 1.50.1 (must match `package.json`)
|
||||
**Version**: 1.50.0 (must match `package.json`)
|
||||
|
||||
## Project Overview
|
||||
|
||||
@@ -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`, `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`.
|
||||
**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`.
|
||||
|
||||
**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)
|
||||
|
||||
@@ -194,7 +194,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
||||
|
||||
**Local model recommendation** (`GET /api/local-model`, `src/local-model/`, `src/config/local-model-catalog.ts`, `src/types/local-model.ts`; EXPERIMENTAL): the server looks at the Mac it runs on and says which local coding model fits it and with which agent CLI. ⚠️ The READ side stays read-only: the probe, the catalogue and `recommend()` download nothing, install nothing and write nothing; everything that changes the machine is the install below, behind an explicit request. Apple silicon macOS only: anywhere else the answer is a `200` with verdict `none` (reasons `not-macos`, `intel`, `memory`, `disk`, `macos`, `no-harness`), and a failed probe is an answer, never a 500. The probe (`hardware.ts`) is ONE bounded async spawn of `/usr/sbin/sysctl` WITHOUT `-n`, parsed as `key: value` lines by the pure `parseSysctl()`, plus `statfs` of the data dir (probed bounded first, #516) and `node:os` fallbacks, cached 60 s; ⚠️ never `system_profiler`, never a second spawn, and `iogpu.wired_limit_mb` of 0 means not set. The catalogue is a compiled constant validated by a Zod schema: ⚠️ no override file and no runtime data, and a model id is `name:tag` in lowercase with no slash (`LOCAL_MODEL_ID_PATTERN`, so an id can never name another registry host). ⚠️ A harness is a catalogue ROW (`{ cli, minContext, rank, gated?, slowFirstTurn? }`) plus a `HarnessFact` built from the CLI registry at call time (`harness-facts.ts`); no code compares a CLI id (both id-branching guards scan these files). `recommend()` (`recommend.ts`) is pure. ⚠️ A model's need is its PEAK (`bytes + heldBytesPerToken * context + overheadBytes + peakExtraBytes`, measured on a Mac for one row and marked `estimated` on the others), and every row names its `runner` (`mlx` or `llamacpp`, the build a Mac really pulls) and carries figures of its OWN: computed from its model's config.json by that runner's rule (catalogue header, MEMORY), never borrowed from another row; `test/local-model-catalog.test.ts` recomputes them, and the `llamacpp` rows assume the runtime's `OLLAMA_KV_CACHE_TYPE=q8_0`, `LLAMA_ARG_CACHE_RAM=1024` and `LLAMA_ARG_CTX_CHECKPOINTS=8` (`runtimeEnv()`) and count what llama-server keeps beside the cache (8 context checkpoints of the recurrent or sliding-window state, and for a GGUF with a prediction layer one rollback copy of the recurrent state per drafted token and that layer's cache in f16), so change the two together. The need is judged against a budget that is `estimated` until the runtime ran and `measured` from then on (`RecommendOptions.measuredTotalBytes`). ⚠️ Ranking, lexicographic: tested on a Mac like this first, then speed `ok` before `unknown` before `slow`, then model rank, harness rank, larger context; the fit grade decides whether a row is listed and what it says, it does NOT sort, so a model is offered at the largest context it fits at. ⚠️ `limited` is a statement about the MAC (no model with 12 billion parameters or more fits), judged before the disk filter and whatever the pick is; a small pick under `ok` carries `noteSmallPick`, and a model left out for disk space is named in `noteDiskDropped`, never dropped in silence. ⚠️ The word "Recommended" exists only in `headlineTested`, and the tag "Tested on a Mac like this" only on a row whose model has a `tested` entry for exactly this chip, memory size and harness. ⚠️ Speed is `ok`/`slow`/`unknown`, graded on the unrounded figure, and a chip in no chips row is `unknown`: never guess a bandwidth. ⚠️ ONE unit for every computed size a person reads: decimal GB text from `formatGb()` (`format.ts`), sent beside the bytes, and the browser never divides bytes; the one exception is the installed memory, shown as `memClassGb` (the size the Mac is sold as, 48 and not 51.5). ⚠️ Every sentence the server chooses is a `LOCAL_MODEL_LABELS`, `LOCAL_MODEL_TAG_LABELS` or `LOCAL_MODEL_INSTALL_LABELS` text with `{placeholder}` slots filled from `vars` (no apostrophe in a text: the zh-CN key is single-quoted), and each needs a zh-CN entry. Admin only in multi-user mode (403 before anything is probed). Tests: `test/local-model-*.test.ts`, `test/routes/local-model-routes.test.ts`. Window rules: `src/web/CLAUDE.md` (Run a model on this Mac). → `docs/api-reference.md` (Local model), `docs/versioning-policy.md`
|
||||
|
||||
**Local model install** (`POST /api/local-model/install|cancel|resume|runtime`, `DELETE /api/local-model`; `src/local-model/{install-job,runtime,ollama-client,connect,state-store,paths,developer-tools}.ts`; EXPERIMENTAL, admin only in multi-user mode on EVERY verb; guide `docs/local-models.md`): installs the chosen model on the Mac the server runs on and ends on a session of the coding agent running on it. ONE job of five idempotent steps (`runtime`, `model`, `verify`, `agent`, `connect`; `agent` only when the coding agent is missing), a pure reducer (`reduceInstallJob()`) over three tables in `install-job.ts` driven by one service per process (`getLocalModelService()`); the window polls the GET once a second, no SSE event. ⚠️ Layout only through `localModelPaths()` (all `dataPath()`): `local-model/runtime/ollama-<version>/`, `local-model/models/`, `local-model/home/` (the runtime's PRIVATE HOME, so the user's `~/.ollama` is never touched) and the state file `local-model.json` (0600, ONE serialized writer, total reader, holds NO path); nothing is written or deleted outside `local-model/` except that file and the two writes of the connect step. ⚠️ The runtime pin (`OLLAMA_RUNTIME_PIN`: version, URL, size, sha256) is a COMPILED constant no file, setting or request may supply; the hash is compared BEFORE anything is unpacked. ⚠️ The runtime is a NON-detached child whose environment comes from the `runtimeEnv()` ALLOWLIST, never `process.env` (no `CODEMAN_*`, none of the user's `OLLAMA_*`); the port is chosen ONCE by binding (from 11435, never 11434) and stored; a listener on it is adopted or replaced only when its stored pid runs a binary under OUR runtime folder, anything else is `port-in-use`; started at boot when installed and enabled, max 3 restarts, SIGTERM at shutdown beside `stopDeepSeekWeb()`; under vitest nothing spawns or fetches. ⚠️ Consent is bound to the size: `acknowledgedBytes` must equal the server's own total (`Candidate.installBytes`) or the answer is 409 `size-changed`; the model, context and CLI must be a row the current recommendation offers (400). ⚠️ ONE job at a time (409 `job-running`); a job a restart interrupted comes back `paused` and NEVER continues without a click (so `shutdown()` writes nothing), and an interrupted step is never recorded as failed. ⚠️ An event the transition table does not list is refused (same object back, 409 `wrong-state`): add a row to the table, never a special case. ⚠️ The budget is MEASURED from the runtime's "inference compute" log line and the model judged against it BEFORE it is downloaded. ⚠️ The pull stream is parsed incrementally (about 1.4 million lines for 23 GB): never buffer it, never write state per line. ⚠️ A failure is a code with ONE next step (`LOCAL_MODEL_ERROR_NEXT`), never a bare "failed"; a probe that ended on its token limit is `probe-unfinished`, not `no-tool-call`. ⚠️ The coding agent is installed through the shared runner `src/cli-install-runner.ts` (extracted from `cli-registry-routes.ts`, ONE install slot per CLI for both callers) with `stockInstallCommand()`, the COMPILED stock command, never the `clis.json`-merged one (this path has no `cliManagementEnabled` gate); only for a catalogue harness row, after the consent click that showed the literal command, audited (`cli_install`, `via: 'local-model'`); afterwards `forgetResolvedCli()` and a fresh lookup, and still missing is `harness-failed`. ⚠️ Connect writes ONE endpoint record under the RESERVED id `local-ollama` through `mutateCustomModelHosts()` and `customModelEndpointsEnabled: true` through `mutateSettingsFile()`, remembering the flag's first value; a record with that id that is not ours is never overwritten, and while it is ours it is MANAGED: the endpoint routes refuse to edit or delete it and the discovery sweep skips it (the runtime's port has no login, so its `models` must stay the one model that was checked). ⚠️ The model digest is stored and read again at boot and before every START of a coding agent on that endpoint (`launchRefusalMessage()`): the two custom-model launch routes in `session-routes.ts` ask it before anything is written, the reboot-restore rebuild asks it before it builds (reason `local-model-not-ready`, left on offer), and `Session`'s launch guard (`setLaunchGuard()`, set in `setupSessionListeners()`) asks it right before a NEW agent process starts (a fresh pane, the respawn of a dead one, `restartCli()`), never for an attach to an agent that runs; a refusal is a `LaunchRefusedError` that never falls back to a direct PTY. A changed or missing model, a runtime Codeman did not start or that did not start, or another model id refuses the launch with one sentence. ⚠️ The connection is CLAIMED in the state file (`connection`, with the flag's value read by `inspectLocalEndpoint()`) BEFORE the endpoint record and the settings flag are written, so a state write that fails or a kill between the two never leaveLine truncated
|
||||
**Local model install** (`POST /api/local-model/install|use|cancel|resume|runtime`, `DELETE /api/local-model`, `DELETE /api/local-model/models`; `src/local-model/{install-job,runtime,ollama-client,connect,state-store,paths,developer-tools}.ts`; EXPERIMENTAL, admin only in multi-user mode on EVERY verb; guide `docs/local-models.md`): installs the chosen model on the Mac the server runs on and ends on a session of the coding agent running on it. ONE job of five idempotent steps (`runtime`, `model`, `verify`, `agent`, `connect`; `agent` only when the coding agent is missing), a pure reducer (`reduceInstallJob()`) over three tables in `install-job.ts` driven by one service per process (`getLocalModelService()`); the window polls the GET once a second, no SSE event. ⚠️ A LIBRARY with ONE model in use: the store may hold several models and `LocalModelStateFile.library` lists the ones that passed the check (written by the service when `verify` passes, carried by the reducer, dropped by `remove`; an `installed` file without it counts its one model, `libraryOf()`), but the endpoint record lists ONE model, the runtime holds one and only the state's model is ever launched: never widen the record to every checked model. `install` from `installed` adds a model (the earlier one stays: `replacedModelFor()` never names a library model), `use` switches to a checked one (a job of `USE_JOB_STEPS`, `verify` then `connect`; the stored digest is compared before anything is loaded, else `model-changed`), and both answer 409 `sessions-open` while a live session uses the endpoint; until such a job ends `installed`, no session starts (`launchRefusal()` answers `job-unfinished`). `DELETE /api/local-model/models` deletes ONE model by its NAME (`LOCAL_MODEL_ID_PATTERN`, never a path), only a name of the server's own listing, only through the runtime's own delete, never the model of the installation or of the current job (409 `model-active`), and it is no reducer event: its row is the `delete-model` action of `INSTALL_STATE_TABLE`. ⚠️ The view's `library` (state entries joined with the runtime's `/api/tags`: `ready`, `changed`, `missing`, `unverified`) and `store` (the blob folder's size, `measureModelStore()`) are cached (5 s, 30 s), refreshed in the background and thrown away by every verb and step that changes them (`forgetStore()`): never ask the runtime or the disk on the one-second poll, and a runtime that does not answer keeps the last list. ⚠️ Layout only through `localModelPaths()` (all `dataPath()`): `local-model/runtime/ollama-<version>/`, `local-model/models/`, `local-model/home/` (the runtime's PRIVATE HOME, so the user's `~/.ollama` is never touched) and the state file `local-model.json` (0600, ONE serialized writer, total reader, holds NO path); nothing is written or deleted outside `local-model/` except that file and the two writes of the connect step. ⚠️ The runtime pin (`OLLAMA_RUNTIME_PIN`: version, URL, size, sha256) is a COMPILED constant no file, setting or request may supply; the hash is compared BEFORE anything is unpacked. ⚠️ The runtime is a NON-detached child whose environment comes from the `runtimeEnv()` ALLOWLIST, never `process.env` (no `CODEMAN_*`, none of the user's `OLLAMA_*`); the port is chosen ONCE by binding (from 11435, never 11434) and stored; a listener on it is adopted or replaced only when its stored pid runs a binary under OUR runtime folder, anything else is `port-in-use`; started at boot when installed and enabled, max 3 restarts, SIGTERM at shutdown beside `stopDeepSeekWeb()`; under vitest nothing spawns or fetches. ⚠️ Consent is bound to the size: `acknowledgedBytes` must equal the server's own total (`Candidate.installBytes`) or the answer is 409 `size-changed`; the model, context and CLI must be a row the current recommendation offers (400). ⚠️ ONE job at a time (409 `job-running`); a job a restart interrupted comes back `paused` and NEVER continues without a click (so `shutdown()` writes nothing), and an interrupted step is never recorded as failed. ⚠️ An event the transition table does not list is refused (same object back, 409 `wrong-state`): add a row to the table, never a special case. ⚠️ The budget is MEASURED from the runtime's "inference compute" log line and the model judged against it BEFORE it is downloaded. ⚠️ The pull stream is parsed incrementally (about 1.4 million lines for 23 GB): never buffer it, never write state per line. ⚠️ A failure is a code with ONE next step (`LOCAL_MODEL_ERROR_NEXT`), never a bare "failed"; a probe that ended on its token limit is `probe-unfinished`, not `no-tool-call`. ⚠️ The coding agent is installed through the shared runner `src/cli-install-runner.ts` (extracted from `cli-registry-routes.ts`, ONE install slot per CLI for both callers) with `stockInstallCommand()`, the COMPILED stock command, never the `clis.json`-merged one (this path has no `cliManagementEnabled` gate); only for a catalogue hLine truncated
|
||||
|
||||
**MCP server sync** (opt-in, `mcpSyncEnabled`, SYNCED, default OFF; `src/mcp-sync.ts`, `GET`/`POST /api/mcp-sync`): copies each installed, enabled CLI's user-level MCP servers into the others. It is the ONE subsystem that writes another CLI's REAL user config (`~/.claude.json`, `~/.codex/config.toml`, `~/.gemini/*`, opencode's), which is why it is opt-in and admin-only in multi-user mode (both verbs 403 for a non-admin, and the Settings group is hidden for them). Where each CLI keeps the file is registry data, `capabilities.mcpConfig` (`{ path, format, relocation? }`), never a branch on the id. ⚠️ Tools that sync but are not run modes (GitHub Copilot CLI, the first) are plain data in `MCP_SYNC_ONLY_TOOLS` (`src/mcp-sync-targets.ts`), take part only when installed or already configured, and their companion `settings.json` (`disabledMcpServers`) is read, never written; a test must clear their relocation vars too. ⚠️ ADDITIVE only: a name already defined, in any shape, is never edited or removed (a different same-name definition is a reported conflict), and a server switched off in its own CLI is never copied. ⚠️ Never write a file that did not parse; re-parse the NEW text and require every added server to read back before the tmp+rename (written through a symlink, previous file kept as `<file>.codeman-bak`, one apply at a time, else 409). ⚠️ A file that receives copied `env`/`headers` (secrets) is left `0600`, and so is the backup. ⚠️ Responses carry server NAMES only, never env values, headers or file text: a parse failure is reported by line and column (`describeMcpSyncError`), never the parser's own message (smol-toml and V8 both quote source). ⚠️ `mcpConfig.relocation` names the env var the CLI reads to move its file (`CLAUDE_CONFIG_DIR`, `CODEX_HOME`, `XDG_CONFIG_HOME`, `GEMINI_CLI_HOME`, and `COPILOT_HOME` for Copilot), resolved from the SERVER env at call time; a relative value reports the target `skipped`, never a guessed write, and a per-session `envOverrides` relocation is not followed. Tests must pass `home` (which drops the `process.env` default) or clear those vars first. → `docs/cli-registry.md` (MCP server sync), `docs/api-reference.md`, `docs/wiki/Settings-Reference.md`
|
||||
|
||||
@@ -216,7 +216,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
||||
|
||||
**Agent inbox** (discussion #558; persistence `agentInboxPersistEnabled`, SYNCED, default OFF): per-session mailbox in `web/agent-inbox.ts`, routes `routes/inbox-routes.ts`, CLI `codeman agent post|inbox|ack`. Never writes to a pane, never in `toState()` (counts via `GET /api/agent-inbox/summary`). ⚠️ Read until ack by id; seen/ack only for the receiver resolved via `resolveParentSessionId()` (ack from anyone else = 403); `?wait=` parks while nothing is UNSEEN; full = 409, never drop the oldest; dropped on delete (`killMux`) only, boot prune only after a successful restore. → [architecture-invariants#agent-inbox](docs/architecture-invariants.md#agent-inbox)
|
||||
|
||||
**Voice dictation via Claude** (`claudeVoiceEnabled`, SYNCED, default OFF): the mic transcribes through this machine's Claude Code login (the CLI `/voice` backend) instead of Deepgram; the browser captures, `src/web/voice-stream.ts` relays to Anthropic. ⚠️ The OAuth token never reaches the page. ⚠️ Credentials are READ-ONLY (`src/claude-credentials.ts`): never refresh them (it rotates the refresh token and can sign the user out of their CLI). ⚠️ Capture must be linear16/16 kHz/mono via an AudioWorklet; `voice-pcm-worklet.js` borrows voice-input.js's `?v=` token, so edit the two together. ⚠️ Claude transcript frames are cumulative within each utterance: replace interims, append endpoint finals once, and keep recording until Stop or the configured silence deadline. → [architecture-invariants#voice-dictation-via-claude](docs/architecture-invariants.md#voice-dictation-via-claude)
|
||||
**Voice dictation via Claude** (`claudeVoiceEnabled`, SYNCED, default OFF): the mic transcribes through this machine's Claude Code login (the CLI `/voice` backend) instead of Deepgram; the browser captures, `src/web/voice-stream.ts` relays to Anthropic. ⚠️ The OAuth token never reaches the page. ⚠️ Credentials are READ-ONLY (`src/claude-credentials.ts`): never refresh them (it rotates the refresh token and can sign the user out of their CLI). ⚠️ Capture must be linear16/16 kHz/mono via an AudioWorklet; `voice-pcm-worklet.js` borrows voice-input.js's `?v=` token, so edit the two together. ⚠️ Claude transcript frames are cumulative: replace, never append. → [architecture-invariants#voice-dictation-via-claude](docs/architecture-invariants.md#voice-dictation-via-claude)
|
||||
|
||||
**Agent Teams**: `TeamWatcher` polls `~/.claude/teams/`, matches to sessions via `leadSessionId`. Teammates are in-process threads appearing as subagents. Enable: `CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1`. See `docs/agent-teams/`.
|
||||
|
||||
@@ -243,8 +243,6 @@ The terminal provider scans `terminalLinkLine()`, which extends `terminalPathLin
|
||||
|
||||
**File Viewer edit mode** (issue #212): the file-preview overlay edits workspace text files in place: `GET .../file-content?edit=1` + `PUT /api/sessions/:id/file-content`, policy in `src/config/file-editing.ts`. This is a **third file surface and the only one that writes file contents** (the path picker's New folder creates folders only): read-path confinement (realpath + workspace + ownership, where an absolute path inside a followed outside worktree (`SessionState.outsideWorktree`) counts as a second workspace root) plus sensitive/blocked/`.git` denies and an extension **allowlist**; writes are `wx`-temp + rename (no `O_CREAT` anywhere = edit-in-place is structural); optimistic concurrency via sha256 `baseHash` → 409. ⚠️ `edit=1` never truncates and the client must never save a plain-preview buffer (the 500-line truncation would silently delete the rest). ⚠️ CRLF/UTF-8 guards: EOL re-applied server-side, non-UTF-8 refused via round-trip compare. → [architecture-invariants#file-viewer-edit-mode](docs/architecture-invariants.md#file-viewer-edit-mode), `docs/file-viewer-edit-plan.md`
|
||||
|
||||
**Files panel delete** (salvaged from #529; `DELETE /api/sessions/:id/files?path=`, the × on a file row of the Files panel, tree and search results): removes ONE regular file and is the **only file surface that deletes**. It takes the edit route's confinement and guards whole (ownership via `findSessionOrFail`, `validateLocalSessionFilePath` behind `withGuardedOutsideWorktree`, so an absolute path in a followed outside worktree obeys the same rules, sensitive/blocked/`.git` 403), and both spellings are checked, the realpath and the path as named. ⚠️ **A symbolic link is refused (400), never followed**: the confinement check returns the REALPATH, i.e. the link's target, so an `unlink` of it deleted `CLAUDE.md` for `AGENTS.md -> CLAUDE.md` and left the link dangling. The final component is `lstat`ed on the path AS NAMED, only a plain file goes on, the inode is compared again before `unlink`, and `unlink` never follows a final link. A dangling link fails realpath and reads as 404. ⚠️ Remote sessions are refused before any path check (a same-named local folder would take the delete), and so are adopted ones (the `/tmp` file-surface exception is not copied to a new surface); Docker workspaces are allowed like the edit route. The workspace (and the outside worktree root) is probed bounded first (#516, `422` on a dead mount). ⚠️ The button is bound from the row's escaped `data-path` in the `.file-tree-item` loops, NEVER an inline `onclick` (`encodeURIComponent` leaves `'` alone, so `x'-alert(1)-'.txt` ran script); it asks with `confirm()`, reloads through `_reloadFileBrowserKeepingQuery()` so a search re-runs, stays visible under `@media (hover: none)`, and is not drawn for remote or adopted sessions. Tests: `test/routes/file-delete-routes.test.ts`, `test/file-delete-ui.test.ts`. → [architecture-invariants#files-panel-delete](docs/architecture-invariants.md#files-panel-delete)
|
||||
|
||||
**Files panel search** (COD-236, the `q` param on `GET /api/sessions/:id/files`): `compileFileQuery()` (`utils/file-query.ts`, pure) compiles the query into a predicate the server-side walk prunes with; a query returns a FLAT match list and the walk recurses past non-matching directories. An empty, whitespace-only or overlong (`MAX_QUERY_LENGTH`, 256) query compiles to `null`, keeping the default tree response byte-identical. ⚠️ **Never compile a glob into a RegExp** (`*a*a*a…` backtracks and freezes the event loop for the whole server): `globMatch()` is a two-pointer wildcard walk. Tree and search share one walk shape: a per-directory round-robin (breadth-first, so fair per directory, not per subtree) over one 5,000-node budget, where a folder's own files count as one share beside the subfolders it just found (`walkTurnEnd()`). ⚠️ That budget caps what is LISTED, never what is read: a read keeps only what the listing can use (`readWalkFolder()`) and `MAX_WALK_ENTRIES_READ` (250,000) stops opening folders, or one GET reads the whole workspace into memory. ⚠️ A folder below the walk's root is probed (#516) before its `readdir`, and anything but `directory` is skipped unflagged. ⚠️ A directory has its node before it is read, so every cursor still queued or unread when the walk ends must get `incomplete: true` or it reads as an empty folder. → [architecture-invariants#files-panel-search](docs/architecture-invariants.md#files-panel-search)
|
||||
|
||||
**Files panel in a worktree** (#765, `src/live-worktree.ts`, `root=live` and `root=live-any` on `GET /api/sessions/:id/files`): Claude's EnterWorktree moves the process, and `paneCwd` follows it (tmux reads the process cwd, measured on 2.1.318, back out on ExitWorktree too), while `workingDir` stays the session's identity. `refreshLiveWorktree()` (server.ts) turns a changed `paneCwd` into `SessionState.liveWorktree` by reading the `.git` FILE (never spawning git), walking up and stopping at `workingDir`, a repository or submodule of its own, or anything unreadable. That is the lookup for a pane inside `workingDir`; a pane outside it goes to `findOutsideWorktree()`, described below. ⚠️ The security argument has two halves. For a worktree inside `workingDir` the lookup only answers a folder strictly below it, but that is a LEXICAL judgement made when the pane moves, and a symlink can replace the folder or a parent later without moving the pane, so `root=live` re-checks on every request (`canWalkLiveWorktree()`, file-routes.ts: walk the worktree only when it resolves strictly inside the resolved `workingDir`, else walk `workingDir`). The walk starts there, returned `path`s stay relative to `workingDir`, and the routes keep confining to `workingDir`, so never make an INSIDE `liveWorktree` a confinement root. ⚠️ Only that check uses resolved paths: the walk keeps the session's spelling, or a workspace reached through a symlink stops producing paths relative to `workingDir`. ⚠️ A plain subfolder is deliberately no answer, or a `cd` in a shell would re-root the panel. ⚠️ A listing reports `worktree` whichever folder it walked, even when the worktree has gone or the check refused it: `_followFileBrowserWorktree()` compares that with the session's state, and an absent report would reload forever. The one exception is a listing without `root=live-any`, which omits an outside worktree; while the session folder is pinned, the panel compares with the worktree it pinned away from (`pinnedWorktree`, panels-ui.js). ⚠️ **A worktree OUTSIDE `workingDir` (`git worktree add ../feature`, or an EnterWorktree placed beside the repo) IS a second root**, answered only through `findOutsideWorktree()` with `outside: true` and published on the wire as `SessionState.outsideWorktree`, never in `liveWorktree`, which stays inside-only: the session's own repository must vouch for it by FILE READS (`workingDir` must be the top of its repository or worktree, so a session in a subfolder follows nothing outside it; the candidate's `.git` FILE names `<common dir>/worktrees/<id>` and that entry's `gitdir` names the candidate back, or its `.git` FOLDER is the session's common dir itself, which is the main worktree for a session started in a linked one, and a symlinked `.git` is refused; never spawn git from the pane watcher). It is refused at `/`, home or above, around `workingDir` and at/inside/around a Docker case workspace (an unreadable Docker list follows nothing). It is refused while attachment workspace confinement is on. It is also refused at, inside or around an attachment blocked tree, and at a root that is itself a sensitive folder such as `~/.ssh` (a child probe, since the sensitive patterns describe file locations). These rules are judged when the pane moves like the Docker rule, and multi-user mode applies `folderAllowedForUser`. The routes also re-check confinement and the blocked trees on every request (`isOutsideWorktreeRootRefused()`), so turning confinement on or blocking the folder stops serving it at once. ⚠️ `validateLocalSessionFilePath()` also refuses any file there that `isSensitivePath()` blocklists, so a root such as a credential folder can never serve its keys. Walking it is opt-in: only `root=live-any` (which the panel sends) lists it, with ABSOLUTE `path`s, while `root=live` never walks an outside worktree: it walks `workingDir` with relative paths, and its listing omits `worktree` for an outside one. An absolute path inside it is confined to it, falling back to `workingDir` when the worktree root refuses it (`validateLocalSessionFilePath()`, route-helpers.ts), while a relative path still resolves against `workingDir` alone. ⚠️ It is recorded as a REAL path, and every request re-checks that the folder still resolves to exactly that path (`outsideWorktreeRootFor()`, `canWalkOutsideWorktree()`), or a symlink swapped in would re-root the routes anywhere. Tests: `test/live-worktree.test.ts`, `test/live-worktree-outside.test.ts`, `test/routes/file-routes-live-worktree.test.ts`, `test/routes/file-routes-outside-worktree.test.ts`, `test/file-browser-worktree.test.ts`, `test/file-browser-outside-worktree.test.ts`. → [architecture-invariants#files-panel-in-a-worktree](docs/architecture-invariants.md#files-panel-in-a-worktree)
|
||||
@@ -267,7 +265,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`/`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`
|
||||
**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`
|
||||
|
||||
**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)
|
||||
|
||||
@@ -330,7 +328,7 @@ One module per domain in `src/web/routes/` (plus a barrel; `ls src/web/routes/`
|
||||
|
||||
## State Files
|
||||
|
||||
All in `~/.codeman/`: `state.json` (sessions, settings, respawn, orchestrator, cron jobs/runs, owner tab layouts, case default groups), `mux-sessions.json` (tmux recovery), `settings.json` (user prefs), `push-keys.json` + `push-subscriptions.json`, `session-lifecycle.jsonl` (audit log), `update-status.json` (self-updater progress, polled across the service restart), `docker-env-applied.json` (Compose deployment only: sha256 of the Dockerfile + compose file the running container was built from, written by `Start-Codeman.sh`, read by the self-updater's environment gate), `docker-build-source.json` (Compose deployment only: the checkout's HEAD commit and `package-lock.json` hash the `codeman-node-modules`/`codeman-dist` volumes currently reflect, written by both `Start-Codeman.sh` and a successful in-place self-update, compared to detect and refresh a volume left stale by an externally-triggered rebuild), `linked-cases.json`, `webviews.json` (saved web-tab dashboard URLs), `remote-hosts.json` + `remote-cases.json`, `docker-hosts.json` + `docker-cases.json` + `docker-exports/`, `subagent-window-states.json` + `subagent-parents.json` (subagent window layout, GET/PUT `/api/subagent-window-states`/`-parents`), `hook-secret` (per-instance), `users.json` (multi-user, mode 0600) + `admin-audit.jsonl`, `intents.json` (Read My Mind intent profiles, mode 0600), `agent-inbox.json` (agent inbox snapshot, only while `agentInboxPersistEnabled` is on, mode 0600), `edit-history/<id>.json` (Edit history panel, one store per session, every diff an agent wrote, mode 0600), `webhook.json` (webhook notifications: enabled/service/scope plus the ntfy/Slack/Discord URL, a bearer secret, so mode 0600, kept out of `settings.json` and never returned by `/api/webhook`, which is admin-only in multi-user mode), `discord.json` (Discord bridge: bot token, a bearer secret, so mode 0600, never returned by `/api/discord`, admin-only in multi-user mode) + `discord-bridge.json` (sessionId to Discord thread id), `local-model.json` (local model install: state, step, model, port, pid, digest, the previous `customModelEndpointsEnabled` value; mode 0600, holds no path, not part of a config backup) + `local-model/` (the pinned runtime, the model store and the runtime's private HOME, removed by `DELETE /api/local-model`), `agent-skill-default-migrated.json` (written once by the 1.50.0 boot migration of `agentSkillEnabled`; while it exists the migration never runs again, so deleting it would switch a stored off on at the next start; never tracked or restored), `config-backup-id` (the random id config backups record as each manifest's `origin`, so instances sharing a backup folder keep to their own snapshots; mode 0600, never tracked or restored, and deleting it unlists this instance's earlier snapshots), `backups/config/` (the default config-backup folder, moved by `configBackupDir`: `cfg-YYYYMMDD-HHMMSS/` snapshots, 0700 with 0600 files, each with a `manifest.json` of SHA-256s, the origin and the retention in force (`keepCount`/`keepDays`), which a pass falls back to when `settings.json` lacks a retention key), `certs/` (self-signed TLS for `--https`), `.env` (CODEMAN_USERNAME/PASSWORD fallback for the `codeman attach` CLI), `install.log` (installer step output, written by `install.sh`'s `run_step`) `tailscale-rename` (the node name before `install.sh` renamed it, so uninstall can offer it back), `cli-installed` (empty, touched by `install.sh` when it installs a CLI: a running server reads only its mtime, and a change ends that CLI's negative-cache backoff) and `runtime/` (the Node.js and tmux `install.sh` downloaded on its no-admin route, linked from `~/.local/bin`; all four installer-route only). Transient: `self-update-runner.sh`. Multi-user case spaces live OUTSIDE the data dir at `~/codeman-users/<username>/cases` (shared across instances like `~/codeman-cases`, override `CODEMAN_USER_SPACES_DIR`).
|
||||
All in `~/.codeman/`: `state.json` (sessions, settings, respawn, orchestrator, cron jobs/runs, owner tab layouts, case default groups), `mux-sessions.json` (tmux recovery), `settings.json` (user prefs), `push-keys.json` + `push-subscriptions.json`, `session-lifecycle.jsonl` (audit log), `update-status.json` (self-updater progress, polled across the service restart), `docker-env-applied.json` (Compose deployment only: sha256 of the Dockerfile + compose file the running container was built from, written by `Start-Codeman.sh`, read by the self-updater's environment gate), `docker-build-source.json` (Compose deployment only: the checkout's HEAD commit and `package-lock.json` hash the `codeman-node-modules`/`codeman-dist` volumes currently reflect, written by both `Start-Codeman.sh` and a successful in-place self-update, compared to detect and refresh a volume left stale by an externally-triggered rebuild), `linked-cases.json`, `webviews.json` (saved web-tab dashboard URLs), `remote-hosts.json` + `remote-cases.json`, `docker-hosts.json` + `docker-cases.json` + `docker-exports/`, `subagent-window-states.json` + `subagent-parents.json` (subagent window layout, GET/PUT `/api/subagent-window-states`/`-parents`), `hook-secret` (per-instance), `users.json` (multi-user, mode 0600) + `admin-audit.jsonl`, `intents.json` (Read My Mind intent profiles, mode 0600), `agent-inbox.json` (agent inbox snapshot, only while `agentInboxPersistEnabled` is on, mode 0600), `edit-history/<id>.json` (Edit history panel, one store per session, every diff an agent wrote, mode 0600), `webhook.json` (webhook notifications: enabled/service/scope plus the ntfy/Slack/Discord URL, a bearer secret, so mode 0600, kept out of `settings.json` and never returned by `/api/webhook`, which is admin-only in multi-user mode), `discord.json` (Discord bridge: bot token, a bearer secret, so mode 0600, never returned by `/api/discord`, admin-only in multi-user mode) + `discord-bridge.json` (sessionId to Discord thread id), `local-model.json` (local model install: state, step, model, port, pid, digest, the checked models of the library, the previous `customModelEndpointsEnabled` value; mode 0600, holds no path, not part of a config backup) + `local-model/` (the pinned runtime, the model store and the runtime's private HOME, removed by `DELETE /api/local-model`), `agent-skill-default-migrated.json` (written once by the 1.50.0 boot migration of `agentSkillEnabled`; while it exists the migration never runs again, so deleting it would switch a stored off on at the next start; never tracked or restored), `config-backup-id` (the random id config backups record as each manifest's `origin`, so instances sharing a backup folder keep to their own snapshots; mode 0600, never tracked or restored, and deleting it unlists this instance's earlier snapshots), `backups/config/` (the default config-backup folder, moved by `configBackupDir`: `cfg-YYYYMMDD-HHMMSS/` snapshots, 0700 with 0600 files, each with a `manifest.json` of SHA-256s, the origin and the retention in force (`keepCount`/`keepDays`), which a pass falls back to when `settings.json` lacks a retention key), `certs/` (self-signed TLS for `--https`), `.env` (CODEMAN_USERNAME/PASSWORD fallback for the `codeman attach` CLI), `install.log` (installer step output, written by `install.sh`'s `run_step`) `tailscale-rename` (the node name before `install.sh` renamed it, so uninstall can offer it back), `cli-installed` (empty, touched by `install.sh` when it installs a CLI: a running server reads only its mtime, and a change ends that CLI's negative-cache backoff) and `runtime/` (the Node.js and tmux `install.sh` downloaded on its no-admin route, linked from `~/.local/bin`; all four installer-route only). Transient: `self-update-runner.sh`. Multi-user case spaces live OUTSIDE the data dir at `~/codeman-users/<username>/cases` (shared across instances like `~/codeman-cases`, override `CODEMAN_USER_SPACES_DIR`).
|
||||
|
||||
**Generated top-level dirs** (all gitignored — don't edit or commit): `dist/` (esbuild output), `out/`, `coverage/`, `test-results/`, `tmp/`, `screenshots-echo-diag/`. The committed gesture bundle (`src/web/public/gesture/gesture-codeman.js`) IS tracked, but its runtime wasm/model assets (`src/web/public/gesture/wasm/`, `*.task`) are fetched and gitignored.
|
||||
|
||||
|
||||
@@ -108,7 +108,7 @@ The first run asks three questions (data folder, port, password; Enter takes the
|
||||
|
||||
**[Download Codeman.dmg](https://github.com/Ark0N/Codeman/releases/latest/download/Codeman.dmg)** · one universal app for Apple Silicon and Intel · macOS 11.3 or later
|
||||
|
||||
A small native app that opens Codeman in its own window. On a Mac without Codeman it installs it for you, with no Terminal and no administrator password, shows the install as a checklist, and offers to install a coding agent if the Mac has none yet. On a Mac that already runs Codeman it finds the server and opens it, or starts it when it is stopped. Under **Another machine** it connects to Codeman on your Mac mini, a home server or your tailnet, with the password kept in the Keychain, and can start an install over there through SSH. On a MacBook Pro with a PROLED XDR display, whose lid logo lights up, **View > Blink Apple Logo** makes the logo show what your agents are doing: it breathes while they work, blinks when one waits for you and flashes when one needs permission, and each state can take a style of your own. It stays off until you turn it on. The app is Rust around macOS's own WebKit, not a bundled browser, so the download is under 5 MB.
|
||||
A small native app that opens Codeman in its own window. On a Mac without Codeman it installs it for you, with no Terminal and no administrator password, shows the install as a checklist, and offers to install a coding agent if the Mac has none yet. On a Mac that already runs Codeman it finds the server and opens it, or starts it when it is stopped. Under **Another machine** it connects to Codeman on your Mac mini, a home server or your tailnet, with the password kept in the Keychain, and can start an install over there through SSH. On a MacBook Pro with a PROLED XDR display, whose lid logo lights up, **View > Blink Apple Logo** blinks the logo by what your agents are doing, with a style of your own for working, for waiting for you and for needing permission; all three stay off until you choose. The app is Rust around macOS's own WebKit, not a bundled browser, so the download is under 5 MB.
|
||||
|
||||
Open `Codeman.dmg`, drag Codeman into Applications and open it from there. macOS asks once whether to open an app downloaded from the internet: click **Open**.
|
||||
|
||||
|
||||
+1
-1
@@ -110,7 +110,7 @@ bash docker/Start-Codeman.sh
|
||||
|
||||
**[下载 Codeman.dmg](https://github.com/Ark0N/Codeman/releases/latest/download/Codeman.dmg)** · 同一个通用应用,适用于 Apple 芯片和 Intel · macOS 11.3 或更高版本
|
||||
|
||||
一个小巧的原生应用,在它自己的窗口里打开 Codeman。在还没有 Codeman 的 Mac 上,它会替你安装,不用终端,也不需要管理员密码,用一张清单显示安装进度;如果这台 Mac 上还没有编程智能体,它还会提出帮你装一个。在已经运行 Codeman 的 Mac 上,它会找到服务器并打开;服务器停着时则替你启动。在 **Another machine** 下,它可以连接你的 Mac mini、家用服务器或 tailnet 上的 Codeman,密码保存在钥匙串里,还能通过 SSH 在那台机器上开始安装。在配备 PROLED XDR 显示屏、机盖 Apple 标志会发光的 MacBook Pro 上,**View > Blink Apple Logo** 会让标志显示智能体在做的事:工作时缓慢呼吸,有智能体等你回复时闪烁,需要权限时快闪三下;每种状态也可以换成你自己选的样式。在你打开之前,它保持关闭。这个应用用 Rust 写成,套在 macOS 自带的 WebKit 外面,不捆绑浏览器,所以下载不到 5 MB。
|
||||
一个小巧的原生应用,在它自己的窗口里打开 Codeman。在还没有 Codeman 的 Mac 上,它会替你安装,不用终端,也不需要管理员密码,用一张清单显示安装进度;如果这台 Mac 上还没有编程智能体,它还会提出帮你装一个。在已经运行 Codeman 的 Mac 上,它会找到服务器并打开;服务器停着时则替你启动。在 **Another machine** 下,它可以连接你的 Mac mini、家用服务器或 tailnet 上的 Codeman,密码保存在钥匙串里,还能通过 SSH 在那台机器上开始安装。在配备 PROLED XDR 显示屏、机盖 Apple 标志会发光的 MacBook Pro 上,**View > Blink Apple Logo** 会让标志按智能体在做的事闪烁:工作中、等你回复、需要权限各用一种你自己选的样式;在你选定之前,三种都保持关闭。这个应用用 Rust 写成,套在 macOS 自带的 WebKit 外面,不捆绑浏览器,所以下载不到 5 MB。
|
||||
|
||||
打开 `Codeman.dmg`,把 Codeman 拖进「应用程序」文件夹,再从那里打开。macOS 会问一次是否打开这个从互联网下载的应用:点**打开**。
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"name": "io.github.Ark0N/codeman",
|
||||
"title": "Codeman",
|
||||
"description": "Start, prompt and read AI coding agent sessions (Claude Code, Codex, Gemini CLI and more) in Codeman",
|
||||
"version": "1.50.1",
|
||||
"version": "1.50.0",
|
||||
"websiteUrl": "https://getcodeman.com",
|
||||
"repository": {
|
||||
"url": "https://github.com/Ark0N/Codeman",
|
||||
@@ -13,7 +13,7 @@
|
||||
{
|
||||
"registryType": "npm",
|
||||
"identifier": "aicodeman",
|
||||
"version": "1.50.1",
|
||||
"version": "1.50.0",
|
||||
"transport": {
|
||||
"type": "stdio"
|
||||
},
|
||||
|
||||
@@ -21,7 +21,6 @@
|
||||
export const BROWSER_TEST_GLOBS = [
|
||||
'test/wrapped-file-paths.browser.test.ts',
|
||||
'test/command-palette-agent-arrows.browser.test.ts',
|
||||
'test/voice-input.browser.test.ts',
|
||||
'test/tmux-capture-sgr.browser.test.ts',
|
||||
'test/tab-rail-resize.browser.test.ts',
|
||||
'test/tab-activation.browser.test.ts',
|
||||
@@ -52,7 +51,6 @@ 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',
|
||||
@@ -64,8 +62,6 @@ export const BROWSER_TEST_GLOBS = [
|
||||
'test/phone-press-feedback.browser.test.ts',
|
||||
'test/desktop-connect-page.browser.test.ts', // no server: the page is served by request interception
|
||||
'test/local-model-ui.browser.test.ts',
|
||||
'test/home-breakpoint-swap.browser.test.ts',
|
||||
'test/log-viewer-reconnect.browser.test.ts',
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
+24
-37
@@ -901,25 +901,6 @@ already serves non-media text inert (`text/plain`, `nosniff`), so this just
|
||||
keeps the server from storing what it would serve or an agent could run, and
|
||||
html and svg are the ones deliberately kept out.
|
||||
|
||||
## Deleting a file (`DELETE /api/v1/sessions/:id/files?path=`)
|
||||
|
||||
The Files panel's delete button. `path` is what the listing gave the row: relative to the
|
||||
session's `workingDir`, or absolute inside the session's outside worktree (see
|
||||
[Live worktree](#live-worktree-liveworktree-outsideworktree)). It removes that one regular file
|
||||
and answers `{ path }` as sent. It never deletes a folder and never follows a symbolic link:
|
||||
the file routes resolve a link to its target, so deleting through one would remove the file it
|
||||
points to.
|
||||
|
||||
| Status | When |
|
||||
| ------ | ---- |
|
||||
| `200` | The file is gone. |
|
||||
| `400` | A remote (SSH) session or an adopted session (both refused before any path is looked at), a missing or repeated `path`, a symbolic link, or anything that is not a regular file. |
|
||||
| `403` | A sensitive file (`.env`, credentials and the like), a blocked attachment tree, or anything under `.git`. |
|
||||
| `404` | No such file, a path outside the workspace (`../`, an absolute path elsewhere, a link leading out), a dangling link, or a session the caller does not own in multi-user mode. |
|
||||
| `409` | The file was replaced between the checks and the delete; reload the listing. |
|
||||
| `422` | The workspace's mount did not answer. |
|
||||
|
||||
## New folder in the path picker
|
||||
## New folder in the path picker
|
||||
|
||||
`POST /api/filesystem/mkdir` with `{ path, name, sessionId?, showHidden? }` makes one
|
||||
@@ -1040,8 +1021,8 @@ of `liveWorktree` and `outsideWorktree`:
|
||||
Like `liveWorktree`, it is never restored after a restart.
|
||||
|
||||
Such a worktree is a second root for the session's file routes. `file-content` (read and edit),
|
||||
`file-raw`, `file-preview`, `file-thumbnail`, `GET /api/v1/download`,
|
||||
`POST /api/v1/sessions/:id/html-view` and `DELETE /api/v1/sessions/:id/files` accept an ABSOLUTE path inside it, while a relative path
|
||||
`file-raw`, `file-preview`, `file-thumbnail`, `GET /api/v1/download` and
|
||||
`POST /api/v1/sessions/:id/html-view` accept an ABSOLUTE path inside it, while a relative path
|
||||
still resolves against `workingDir` only. A file there that the sensitive-path blocklist names
|
||||
(`.env`, `.aws/credentials`, `users.json` and the like) is refused on every one of those routes.
|
||||
Codeman reports the worktree only when the session's own repository lists it, and only when `workingDir` is the top of that repository or of a linked worktree, so a session started in a subfolder of its repository reports none. It never reports:
|
||||
@@ -1614,12 +1595,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`, `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 `{}`.
|
||||
`~/.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 `{}`.
|
||||
|
||||
| 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`, `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/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/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 +1609,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`, `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).
|
||||
`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).
|
||||
|
||||
### System operations
|
||||
|
||||
@@ -1735,7 +1716,7 @@ Only replies from `allowedUserIds`, in `guildId`, inside a thread the bridge cre
|
||||
|
||||
## Local model
|
||||
|
||||
`GET /api/local-model` looks at the machine the server runs on and says which local coding model fits it, with which agent CLI, and where an install of one stands. Five verbs install a model, pause and continue the install, stop and start the model runtime, and remove everything again. It covers Apple silicon macOS only; anywhere else the GET is still a `200`, with verdict `none`, and nothing can be installed. How it works: [local-models.md](local-models.md).
|
||||
`GET /api/local-model` looks at the machine the server runs on and says which local coding model fits it, with which agent CLI, where an install of one stands and which models are on the machine. Seven verbs install a model (a first one, or another one beside it), switch to a model that is already there, pause and continue a job, stop and start the model runtime, delete one model, and remove everything again. It covers Apple silicon macOS only; anywhere else the GET is still a `200`, with verdict `none`, and nothing can be installed. How it works: [local-models.md](local-models.md).
|
||||
|
||||
**Experimental.** These routes and their payloads are labeled experimental: the fields below, the states, the error codes and the sentences may change or go in a MINOR or PATCH release (see [versioning-policy.md](versioning-policy.md), "What SemVer does NOT cover", item 3). Do not build on their shape yet.
|
||||
|
||||
@@ -1744,11 +1725,13 @@ Every route is admin only in multi-user mode (`403 FORBIDDEN`, answered before a
|
||||
| Method | Path | Body | Notes |
|
||||
| -------- | -------------------------- | ------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `GET` | `/api/local-model` | none | `200` with `{ hardware, recommendation, catalogUpdated, install }`. `500 INTERNAL_ERROR` only when the recommendation itself fails, never for a failed probe. |
|
||||
| `POST` | `/api/local-model/install` | `{ modelId, cli, context, acknowledgedBytes }` | Starts the one install job and answers at once; the job runs on in the server. `400 INVALID_INPUT` when the three choices are not a row the server offers right now. `409`: `job-running`, `already-installed`, `size-changed`. |
|
||||
| `POST` | `/api/local-model/install` | `{ modelId, cli, context, acknowledgedBytes }` | Starts the one install job and answers at once; the job runs on in the server. With a model installed it adds another one: the earlier model stays on disk, and the new one is the model in use when the job ends. `400 INVALID_INPUT` when the three choices are not a row the server offers right now. `409`: `job-running`, `already-installed` (this model at this context is the one in use), `size-changed`, `sessions-open`. |
|
||||
| `POST` | `/api/local-model/use` | `{ modelId }` | Switches to a model of `install.library` whose `actions` list `use`. A job of the two steps `verify` and `connect`; nothing is downloaded. `400 INVALID_INPUT` when the model is not a checked model of the library, or no coding agent for it is installed. `409`: `job-running`, `wrong-state` (nothing is installed), `already-installed`, `sessions-open`. |
|
||||
| `POST` | `/api/local-model/cancel` | none | Interrupts the step in flight; the job is `paused` at that step. `409 wrong-state` when no job is running. |
|
||||
| `POST` | `/api/local-model/resume` | `{ newPort? }` (may be absent) | Continues a `paused` or `failed` job at the same step. `newPort: true` chooses another port first. `409`: `job-running`, `wrong-state`, `sessions-open` (`newPort` while live sessions use the model). |
|
||||
| `POST` | `/api/local-model/runtime` | `{ action: "start" \| "stop", newPort? }` | `stop` stops the runtime and keeps it from starting at the next boot; `start` undoes that. `newPort: true` with `start` chooses another port and moves the saved endpoint to it. `409`: `job-running`, `wrong-state` (nothing is installed), `sessions-open` (`stop` or `newPort` while live sessions use the model: their agents keep calling the address they were started with). |
|
||||
| `DELETE` | `/api/local-model` | `{ removeModel? }` (may be absent) | Stops the runtime, removes the saved endpoint, restores the settings flag when this feature switched it on and no other endpoint is saved, deletes the runtime; the model only with `removeModel: true`. `409`: `job-running`, `sessions-open`. |
|
||||
| `DELETE` | `/api/local-model` | `{ removeModel? }` (may be absent) | Stops the runtime, removes the saved endpoint, restores the settings flag when this feature switched it on and no other endpoint is saved, deletes the runtime; the downloaded models (all of them) only with `removeModel: true`. With nothing installed it is what deletes the models an earlier Remove kept. `409`: `job-running`, `sessions-open`. |
|
||||
| `DELETE` | `/api/local-model/models` | `{ modelId }` | Deletes ONE model from the store, through the runtime. `modelId` is a model name (`name:tag`) of `install.library` whose `actions` list `delete`, never a path. `400 INVALID_INPUT` for a name that is not on this machine. `409`: `job-running`, `wrong-state` (nothing is installed), `model-active` (the model of the installation or of the current job), `runtime-not-running`. `500 OPERATION_FAILED` when the runtime did not delete it. |
|
||||
|
||||
Every verb answers `{ success: true, data: <install> }` with the `install` object (below) as it is after the verb. A refusal the feature names is a `409` whose envelope also carries the conflict:
|
||||
|
||||
@@ -1766,9 +1749,9 @@ Every verb answers `{ success: true, data: <install> }` with the `install` objec
|
||||
}
|
||||
```
|
||||
|
||||
`data.reason` is `job-running`, `already-installed` (remove the installed model first), `size-changed` (with `expectedBytes` and `expectedGb`), `sessions-open` (with `sessions`, the names of the live sessions that use the model) or `wrong-state`. A `409 CONFLICT` without `data` means a config file that must not be overwritten (`settings.json` or `custom-model-hosts.json` does not parse); `error` names the fix.
|
||||
`data.reason` is `job-running`, `already-installed` (the model is the one in use already), `size-changed` (with `expectedBytes` and `expectedGb`), `sessions-open` (with `sessions`, the names of the live sessions that use the model), `wrong-state`, `model-active` (a delete of the model in use: switch first, or remove everything) or `runtime-not-running` (a delete while the runtime is down: start it first). A `409 CONFLICT` without `data` means a config file that must not be overwritten (`settings.json` or `custom-model-hosts.json` does not parse); `error` names the fix.
|
||||
|
||||
`POST /api/local-model/install` also answers `400` when a saved model endpoint already uses the id `local-ollama` and was not created by this feature: rename or delete that endpoint first. The check is made before anything is downloaded. Every `400` the install names, and the `507 OPERATION_FAILED` of a first install on a full disk, carries its sentence as `data.message` (`{ text, vars }`, like a `409`), so a client can print it in its own language.
|
||||
`POST /api/local-model/install` also answers `400` when a saved model endpoint already uses the id `local-ollama` and was not created by this feature: rename or delete that endpoint first. The check is made before anything is downloaded. Every `400` the feature names, the `507 OPERATION_FAILED` of a first install on a full disk and the `500 OPERATION_FAILED` of a delete the runtime refused carry their sentence as `data.message` (`{ text, vars }`, like a `409`), so a client can print it in its own language.
|
||||
|
||||
**Consent is bound to the size shown.** `acknowledgedBytes` must be the `installBytes` of the chosen row: the model download plus the runtime download while the runtime is not unpacked yet. The server compares it with its own total before anything starts and answers `409 size-changed` when they differ, so a click confirms exactly the size the person saw.
|
||||
|
||||
@@ -1794,12 +1777,13 @@ A probe that fails is an answer, not an error: a server that is not on macOS, or
|
||||
- `tag?`: `{ kind, label }`, the one tag of the row. `kind` is `tested`, `best`, `tight`, `slow` or `small`;
|
||||
- `fit`: `good` (up to 0.85 of the budget) or `tight` (up to 0.98), with its label `fitLabel`;
|
||||
- `speed`: `ok`, `slow` (under 10 tokens per second) or `unknown`, with `speedLabel` (absent for `ok`) and `tokensPerSecond` (rounded to one decimal, absent for `unknown`; `speed` is decided on the unrounded figure, so `10` can be `slow`). `unknown` means the chip is in no row of the catalogue's chip table, or the catalogue has no speed figure for the model: a speed is never guessed;
|
||||
- `tested`: we ran this model with this CLI on a Mac like this one (same chip, same memory size). Its `tokensPerSecond` is then the speed measured in that run.
|
||||
- `tested`: we ran this model with this CLI on a Mac like this one (same chip, same memory size). Its `tokensPerSecond` is then the speed measured in that run;
|
||||
- `library?`: the model at this context is a checked model on this machine already (see `install.library`). `active`: it is the model in use. `ready`: `POST /api/local-model/use` switches to it, and nothing is downloaded. Absent otherwise.
|
||||
- `budgetBytes` and `budgetGb`: the memory a model may use on this Mac (`0` for `not-macos` and `intel`, where none is computed). `budgetSource` is `estimated` (computed from the installed memory: all there is before the runtime ran), `wired-limit` (taken from `iogpu.wired_limit_mb`) or `measured` (the device total the runtime itself printed when it started; the GET uses it from the first install on).
|
||||
- `catalogUpdated`: ISO date on which the catalogue's rows were last checked.
|
||||
- `install`: where the install stands and what can be done about it.
|
||||
- `state`: `idle` (nothing installed, no job), `installing`, `paused` (Cancel or a server restart stopped the job; it never continues without a click), `failed` or `installed`.
|
||||
- `step` and `steps[]` (present with `installing`, `paused` and `failed`): the step the job is in or stopped in, and the steps of this job in order, out of `runtime`, `model`, `verify`, `agent`, `connect`. `agent` is absent from a job whose agent CLI was already installed.
|
||||
- `step` and `steps[]` (present with `installing`, `paused` and `failed`): the step the job is in or stopped in, and the steps of this job in order, out of `runtime`, `model`, `verify`, `agent`, `connect`. `agent` is absent from a job whose agent CLI was already installed. A switch (`POST .../use`) is the two steps `verify` and `connect`.
|
||||
- `progress` (while a download runs, and as it stood on a paused or failed job): `completedBytes`, `totalBytes`, `completedGb`, `totalGb`, and while it moves `bytesPerSecond`, `mbPerSecond` (whole decimal megabytes per second as text) and `secondsLeft`. During the `model` step both byte figures come from the download's own lines, not from the catalogue.
|
||||
- `error` (with `failed`; with `installed` only when something blocks a session): `{ code, message: { text, vars? }, next, nextModel?, detail? }`. `next` is the ONE action that helps: `retry` (`POST .../resume`), `other-port` (`POST .../resume` or `.../runtime` with `newPort: true`), `next-model` (choose again; `nextModel` is `{ modelId, modelLabel }` when the server knows one), `start` (`POST .../runtime`) or `remove` (`DELETE`). `detail` is raw text for the curious (an installer's last lines), not translated.
|
||||
- `model` (`{ id, label }`), `cli`, `cliLabel`, `context`: the choice of the job or of the installation. `port`: the runtime's loopback port, once chosen.
|
||||
@@ -1809,7 +1793,9 @@ A probe that fails is an answer, not an error: a server that is not on macOS, or
|
||||
- `developerTools`: `present`, `missing` (the Mac has no Xcode or Command Line Tools, so macOS will offer to install them the first time an agent runs `git` or `python3`) or `unknown`.
|
||||
- `runtime`: `{ version, installed, downloadBytes, downloadGb, unpackedGb }`. `downloadBytes` is `0` once the runtime is unpacked.
|
||||
- `folder`: the absolute folder everything of this feature is kept in.
|
||||
- `actions[]`: exactly what the server accepts right now, out of `install`, `cancel`, `resume`, `start`, `stop`, `remove` and `start-session` (not a route here: start a session on `endpointId`; offered only while the runtime runs and nothing blocks a session).
|
||||
- `library[]`: the models on this machine, the model in use first. Empty with `idle` (no runtime is there to ask). Each row: `id` (the name the runtime lists, `name:tag`), `label`, `context?` (the context it was checked with), `sizeBytes?` and `sizeGb?` (its size on disk as the runtime reports it, else as recorded when it was checked), `active` (it is the model in use), `verified` (Codeman checked it), `status` and `actions[]`. `status` is `ready`, `changed` (checked, and the runtime now reports another digest under the name), `missing` (checked, and the runtime no longer lists it) or `unverified` (the runtime lists it and Codeman never checked it: a model an earlier Remove kept, the model of a job that has not passed its check, or one another program pulled through the runtime's port). `actions` is what the server accepts for THAT row right now, out of `use` and `delete`; `delete` needs the running runtime, and a name that is not a plain `name:tag` is listed with no action.
|
||||
- `store?`: `{ bytes, gb }`, what the model store takes on disk (the files of its blob folder, so shared layers count once and an interrupted download is included). Present in every state once it was measured; cached for about thirty seconds.
|
||||
- `actions[]`: exactly what the server accepts right now, out of `install` (with `installed`: add another model), `cancel`, `resume`, `start`, `stop`, `remove` (with `idle` only while `store` holds something), `use` and `delete-model` (only while a row of `library` offers them) and `start-session` (not a route here: start a session on `endpointId`; offered only while the runtime runs and nothing blocks a session).
|
||||
|
||||
Error codes (`install.error.code`), each with one next step:
|
||||
|
||||
@@ -1819,9 +1805,11 @@ Error codes (`install.error.code`), each with one next step:
|
||||
| `start` | `runtime-stopped` (the runtime crashed three times in a row and is no longer restarted) |
|
||||
| `other-port` | `port-in-use` |
|
||||
| `next-model` | `model-not-found`, `does-not-fit`, `no-tool-call` |
|
||||
| `remove` | `model-changed` (the model's digest is no longer the one stored at install time; a session on it is refused) |
|
||||
| `remove` | `model-changed` (the model's digest is no longer the one stored when it was checked; a session on it is refused, and so is a switch to it) |
|
||||
|
||||
Starting a session on the saved endpoint goes through the existing custom model launch (`POST /api/quick-start` with `customModel`, or `POST /api/sessions/:id/custom-model`). For the endpoint this feature manages both answer `OPERATION_FAILED` with one sentence while the runtime is not running, when the model's digest changed, or for a model other than the installed one.
|
||||
Two codes are only ever the reason of a refused session start, never on `install.error`: `runtime-not-running` (the person stopped the runtime) and `job-unfinished` (a job that changes the model in use is running or stopped: nothing is started until it ended `installed` again).
|
||||
|
||||
Starting a session on the saved endpoint goes through the existing custom model launch (`POST /api/quick-start` with `customModel`, or `POST /api/sessions/:id/custom-model`). For the endpoint this feature manages both answer `OPERATION_FAILED` with one sentence while the runtime is not running, while a job that changes the model in use is running or stopped, when the model's digest changed, or for a model other than the one in use.
|
||||
|
||||
Every computed size a person reads travels twice: as bytes, and as decimal GB text without the unit (`"needGb": "29.6"`). Show the text and add the unit; do not divide the bytes yourself, so one request never shows two different roundings. The installed memory is the exception named above: show `memClassGb`.
|
||||
|
||||
@@ -1842,8 +1830,7 @@ same speech-to-text service the CLI's own `/voice` mode uses. Gated on the synce
|
||||
little-endian PCM, 16 kHz mono (<= 64 KB per frame), plus JSON control frames
|
||||
`{"t":"finalize"}` (ask for the final transcript) and `{"t":"stop"}`. Server
|
||||
sends `{"t":"ready"}`, `{"t":"transcript","text","final"}` (each frame is the
|
||||
current utterance so far, not a delta, and a `final: true` frame ends that
|
||||
utterance), `{"t":"error","message"}` and
|
||||
WHOLE running transcript, not a delta), `{"t":"error","message"}` and
|
||||
`{"t":"closed"}`. Close codes: `4003` disallowed Host/Origin, `4004`
|
||||
unavailable (reason in the close reason), `4008` too many concurrent streams.
|
||||
Streams are capped in count and length (`src/config/voice.ts`).
|
||||
|
||||
@@ -468,7 +468,7 @@ The general rule: **any new endpoint that turns a caller-supplied `sessionId` in
|
||||
|
||||
### File Viewer edit mode
|
||||
|
||||
**File Viewer edit mode** (issue #212, design in `docs/file-viewer-edit-plan.md`): the file-preview overlay can edit workspace text files in place — `GET /api/sessions/:id/file-content?edit=1` (read-for-edit) + `PUT /api/sessions/:id/file-content` (save), policy in `src/config/file-editing.ts`, UI in `panels-ui.js`. This is the **only file surface that writes file contents** (the Files panel's delete, [below](#files-panel-delete), removes whole files and borrows these confinement rules), so it carries every rule the read surfaces have plus its own:
|
||||
**File Viewer edit mode** (issue #212, design in `docs/file-viewer-edit-plan.md`): the file-preview overlay can edit workspace text files in place — `GET /api/sessions/:id/file-content?edit=1` (read-for-edit) + `PUT /api/sessions/:id/file-content` (save), policy in `src/config/file-editing.ts`, UI in `panels-ui.js`. This is the **only file surface that writes**, so it carries every rule the read surfaces have plus its own:
|
||||
|
||||
- **Confinement is the read path's, plus write-only gates.** `findSessionOrFail` (ownership) → `validateSessionFilePath` (realpath + workspace boundary; escapes report as 404, same as reads) → sensitive-path + attachment-guard blocklists (403) → `.git/` subtree deny (403 — `.git/hooks/*` is code execution) → extension **allowlist** (400; `svg` and `env` deliberately excluded). ⚠️ **There is no `O_CREAT` anywhere in the handler** — that absence is what makes "edit-in-place only, never create" a structural property instead of a convention. Do not add a create path without treating it as a new security surface.
|
||||
- **A truncated buffer must never become an edit buffer.** The plain preview truncates to `lines` (default 500); saving such a buffer would silently delete everything past the cut, and the hash check cannot catch it (the loaded prefix hashes differently from the full file, which reads as an ordinary conflict at best). `edit=1` therefore never truncates — it 413s over `MAX_EDITABLE_BYTES` (512KB) instead — and the frontend always re-fetches with `edit=1` before swapping in the textarea, even though the preview already holds content.
|
||||
@@ -479,18 +479,6 @@ The general rule: **any new endpoint that turns a caller-supplied `sessionId` in
|
||||
|
||||
Tests: `test/file-editing-policy.test.ts` (pure policy), `test/routes/file-write-routes.test.ts` (deliberately **unmocked fs** against a real temp workspace — symlink/TOCTOU/mode behavior must be exercised for real).
|
||||
|
||||
### Files panel delete
|
||||
|
||||
**Files panel delete** (salvaged from contributor PR #529, which was closed; its upload half was dropped because `.codeman-uploads/` and the Attach button already cover it): a × on each file row of the Files panel, tree and search results alike, backed by `DELETE /api/sessions/:id/files?path=` in `file-routes.ts`. It removes ONE regular file and is the only file surface that deletes, so it takes the edit route's confinement whole and adds a rule of its own.
|
||||
|
||||
- **Order of checks**: `findSessionOrFail` (ownership; a foreign session is a 404 in multi-user mode) → remote refused (400, before any path check, for the same reason as the edit route: `validateSessionFilePath` resolves against the LOCAL filesystem, so a same-named local folder would take the delete) → adopted refused (400) → a missing or repeated `path` (400) → a bounded probe of `workingDir`, and of the outside worktree root for an absolute path (#516; `422` on a mount that does not answer) → `validateLocalSessionFilePath(await withGuardedOutsideWorktree(...))` (realpath + boundary, an absolute path inside a followed outside worktree confined to it, the attachment guard re-read per request; any escape, a dangling link and a missing file are all 404) → sensitive / blocked trees / `.git` (403), each tested against BOTH the realpath and the path as named, so a `.git` reached through a linked folder and a linked `.git` are both caught → the link rule below → not a regular file (a folder: 400).
|
||||
- **A symbolic link is refused, never followed.** The confinement helper returns the REALPATH, which for a link is its target. #529 unlinked that path, so `DELETE ?path=AGENTS.md` with `AGENTS.md -> CLAUDE.md` deleted `CLAUDE.md` and left `AGENTS.md` dangling. The route therefore `lstat`s the path as the client named it (`resolve(workingDir, path)`, which keeps an absolute outside-worktree path as it is) and answers 400 for a link. The listing shows a link to a folder as a file row, so it gets a button, and the same 400 answers it. Deleting the link itself was the other option (confine the PARENT's realpath, unlink the link); it was not taken because refusing has no way to touch the target at all.
|
||||
- **The file checked is the file deleted.** A path whose last part is not a link has a realpath naming the same inode, so the route `lstat`s the realpath again and refuses with 409 if the inode, the device or the file type changed in between. `unlink` never follows a final symlink, so a link swapped in after that still removes only the link. A parent folder swapped for a link between the realpath and the `unlink` is the same check-then-use window every file route accepts (an agent running as the same user can delete the file anyway).
|
||||
- **Adopted and Docker.** An adopted session's folder is the neutral `/tmp`. The older file surfaces keep serving it as a standing exception (root `CLAUDE.md`, Adopted tmux sessions), which is not copied to a new surface, least of all one that deletes; the prompt-upload route refuses adopted sessions the same way. A Docker case workspace is bind-mounted at the same path and an `unlink` runs no hooks or filters (the reason git is kept away from those folders), so the route treats it like the edit route does: allowed.
|
||||
- **Frontend.** The button carries no inline handler: `encodeURIComponent` leaves `'` alone, so #529's `onclick="...decodeURIComponent('<path>')"` let a file named `x'-alert(1)-'.txt` run script in Codeman's origin, and `it's.txt` was a syntax error. It is bound in the existing `.file-tree-item` loops (`renderFileBrowserTree`, `_renderFileBrowserSearchResults`) from the row's escaped `data-path`, with `stopPropagation` so the row does not also open the preview. `deleteFileFromBrowser()` asks with `confirm()` (the panel's other destructive actions use the same native dialog, translated by i18n.js's wrapper) and reloads through `_reloadFileBrowserKeepingQuery()`, which re-runs a search in the box, so a row deleted from search results leaves them. The row buttons stay visible under `@media (hover: none)`, where the old hover-only rule meant the first tap opened the preview instead. `_fileBrowserCanDelete()` draws no button for a remote or an adopted session (`session.remote` is undefined on an adopted one, so `session.adopt` is tested too).
|
||||
|
||||
Tests: `test/routes/file-delete-routes.test.ts` (unmocked fs again: the symlink defect only shows against a real link), `test/file-delete-ui.test.ts` (jsdom with `runScripts: 'dangerously'`, so the old inline handler would really run there, and the real `escapeHtml` from constants.js).
|
||||
|
||||
### File Viewer text view: rendered markdown and text toggles
|
||||
|
||||
**Rendered markdown + Lines/Wrap** (`_renderFilePreviewText()` and its helpers in `panels-ui.js`, buttons in the `.file-preview-actions` row): a `.md`/`.markdown` opened in the File Viewer renders as a document by default, with an `MD` pill back to source; the plain-text view has `Lines` (a CSS-counter gutter) and `Wrap` toggles. Codeman already had `marked` + DOMPurify behind `_renderMarkdown()` for the Response Viewer, so the viewer reuses that and the codebase keeps ONE markdown pipeline.
|
||||
@@ -732,11 +720,19 @@ Either way the actual launch (`runCustomModelEntry`) routes through `run()` itse
|
||||
|
||||
**Remove** stops the child, undoes the connection FIRST (a refused write of the endpoint store or of the settings rejects before any file is deleted), restores the flag only when this feature switched it on AND no other endpoint is saved, then deletes the runtime and the private home and, when asked, the models (`removeLocalModelFiles()`, which refuses any path outside the root). ⚠️ While a live session uses the endpoint the answer is 409 `sessions-open` with the session names (`sessionNamesUsingEndpoint()`, read at the moment of the request). ⚠️ The coding agent stays: what its installer wrote is not ours to remove.
|
||||
|
||||
**The library** (one model in use, several on disk). `LocalModelStateFile.library` lists every model that PASSED the check through Codeman: `{ id, label, context, digest, bytes?, cli?, verifiedAt? }`, no path. The service upserts the entry as a fact when `verify` passes (where it stores the digest); the reducer only carries the list, through every event but `remove`. An `installed` file an earlier build wrote has no list: `libraryOf()` then counts its one model, and an install or a switch from it writes that model into the list before the state's `model` member becomes the new job's. ⚠️ ONE model is in use: the endpoint record still lists exactly one model, the runtime still holds one (`OLLAMA_MAX_LOADED_MODELS=1`), and `launchRefusalMessage()` still starts only the state's model with its stored digest. Never widen the record to "every checked model": the port has no login, and two sessions on two models would make the runtime swap 20 GB on every request.
|
||||
|
||||
⚠️ Three verbs, each a row of the tables and never a special case. **Add**: `install` is also listed for `installed` in `INSTALL_TRANSITIONS`; the job is the same five steps, and the earlier model stays (the `replacedModelId` rule, `replacedModelFor()`, never names a model of the library: only an unchecked or partial download is deleted as a leftover). **Switch**: the event `use` (`installed`, `paused`, `failed`) starts a job of `USE_JOB_STEPS` (`verify`, `connect`) for a library entry, carrying its stored digest in `model.digest`; `stepVerify` compares that digest BEFORE it loads anything and again after the check, and a different or missing one fails with `model-changed`. A switch has no `model` step, so the leftovers of the job it replaced are deleted at the start of `verify` (`dropReplacedModel()`). Its coding agent is the entry's own when that is installed now, else the state's (`recheckCliInstalled()`), else a 400: a job that stopped while it installed another agent leaves the state naming one that is not there. **Delete** (`deleteLibraryModel()`) moves no state, so it is not an event; whether a state allows it is the `delete-model` action of its `INSTALL_STATE_TABLE` row. It takes a model NAME (the request schema and the service both hold it to `LOCAL_MODEL_ID_PATTERN`), requires that name in the server's own listing (the library or the runtime's tags), goes through the runtime's `/api/delete` only (never a file delete, never a path), and trusts only the runtime's answer that the model is gone, never an empty listing. ⚠️ It refuses the model of the installation or of the current job (`model-active`), a running job, and a runtime that is down or does not answer (`runtime-not-running`).
|
||||
|
||||
⚠️ `install` from `installed` and `use` are refused with 409 `sessions-open` while live sessions use the endpoint, like Stop: both change which model the runtime holds and often restart it for another context. ⚠️ While a job that started from `installed` is unfinished (`installing`, `paused`, `failed`) the launch check refuses every start with `job-unfinished` (a code only a refused start says, like `runtime-not-running`), because it wants the state `installed`; the view then offers `use` on the other checked models, which is the way back. A switch is never offered for the model of the stopped job itself: Resume goes on with that one. ⚠️ Both verbs undo a Stop (`undoStop()`, `runtime.enabled` back to true) once their job started: the job runs the runtime, and a model that is in use and running must also start with the server again.
|
||||
|
||||
**The view's two listings.** `install.library` (`buildLibraryRows()`, pure) joins the state's entries with what the RUNNING runtime lists (`/api/tags`): `ready`, `changed` (another digest under the name), `missing` (no longer listed) for a checked model, `unverified` for anything else the runtime lists (a model an earlier Remove kept, the model of a job that has not passed its check, one another program pulled through the unauthenticated port), which can be deleted and never switched to. A name that is not a plain `name:tag` is listed with no action. `install.store` is `measureModelStore()`: the sizes of the regular files directly in `models/blobs`, nothing followed, nothing outside the root. ⚠️ Neither is asked for on the one-second poll: `tagsCache` (5 s) and `storeCache` (30 s) are refreshed in the background, only a figure that was never read is waited for (300 ms at most), a runtime that does not answer keeps the last list (or every checked model would read as `missing` for a moment), and every verb and finished step that changes the store throws both away (`forgetStore()`). ⚠️ In `idle` there is no runtime to ask, so the list is empty and only `store` says that an earlier Remove kept models; `remove` is listed there only while it holds something, and `DELETE /api/local-model { removeModel: true }` is what frees it. `Candidate.library` (`active`, `ready`) is added by the ROUTE (`markLibraryCandidates()`), never by `recommend()`, which stays pure and knows nothing of what is installed.
|
||||
|
||||
**Developer tools**: `checkDeveloperTools()` runs ONLY `/usr/bin/xcode-select -p`, bounded, on darwin. ⚠️ Never `git`, `python3` or `xcrun` from the server: on a Mac without the tools each opens a system window. The view carries `developerTools`, and the window prints the one sentence when it is `missing`.
|
||||
|
||||
**The endpoint store's writer** (`src/custom-model-hosts-writer.ts`): `mutateCustomModelHosts(fn)` is the shape of `mutateSettingsFile()`: one promise chain, a random temp name, a refusal (409) to write over a file that does not parse. ⚠️ EVERY writer of `custom-model-hosts.json` goes through it: the four endpoint routes, the sweep and the connect step (`test/custom-model-hosts-writer.test.ts` fails a `writeCustomModelHosts()` call anywhere else in `src/`). The routes used to read, change and write on their own through one shared temp name, so two creates sent together lost a record or failed, and a file that did not parse was read as `[]` and replaced by one record, with every other endpoint's API key gone. ⚠️ A route's check runs INSIDE the callback, and discover splices onto the record as it is after the probe, not the copy read before it.
|
||||
|
||||
Tests: `test/local-model-install-job.test.ts` (the tables and the reducer, every state with every event), `test/local-model-install-service.test.ts`, `test/local-model-install-e2e.test.ts` (the real modules over the fake runtime), `test/local-model-state-store.test.ts`, `test/local-model-connect.test.ts`, `test/custom-model-hosts-writer.test.ts`, `test/cli-install-runner.test.ts`, `test/routes/local-model-routes.test.ts`, `test/routes/local-model-launch-guard.test.ts`, and the runtime's own `test/local-model-runtime.test.ts`, `test/local-model-ollama-client.test.ts`. The fake runtime is `test/fixtures/fake-ollama.ts`.
|
||||
Tests: `test/local-model-install-job.test.ts` (the tables and the reducer, every state with every event, the library rows), `test/local-model-install-service.test.ts` (the three library verbs and the two caches included), `test/local-model-install-e2e.test.ts` (the real modules over the fake runtime: install A, add B, switch back, delete B), `test/local-model-state-store.test.ts`, `test/local-model-connect.test.ts`, `test/custom-model-hosts-writer.test.ts`, `test/cli-install-runner.test.ts`, `test/routes/local-model-routes.test.ts`, `test/routes/local-model-launch-guard.test.ts`, and the runtime's own `test/local-model-runtime.test.ts`, `test/local-model-ollama-client.test.ts`. The fake runtime is `test/fixtures/fake-ollama.ts`.
|
||||
|
||||
### Approvals Inbox
|
||||
|
||||
@@ -794,7 +790,7 @@ Frontend `readmymind-ui.js`: header 🧠 marker-hidden (`btn-readmymind--hidden`
|
||||
|
||||
⚠️ Capture MUST be linear16/16 kHz/mono, so it uses an **AudioWorklet**, not MediaRecorder (which cannot emit raw PCM); `voice-pcm-worklet.js` is fetched from JS, so it is invisible to `cacheBustAssets` and borrows voice-input.js's `?v=` token — **edit the two together**.
|
||||
|
||||
⚠️ Claude transcript frames are cumulative within the current utterance: replace interims, then append each endpoint final once. An endpoint does not stop dictation unless finalization was requested. Finalization has a 5-second client backstop; reconnect cleanup salvages accumulated/interim text and releases the microphone immediately. Provider choice is `voiceSettings.provider` (`auto` prefers Claude → Deepgram → Web Speech). → `docs/claude-voice-plan.md`
|
||||
⚠️ Transcript frames carry the WHOLE running transcript, not deltas: the Claude path replaces where the Deepgram path appends. Provider choice is `voiceSettings.provider` (`auto` prefers Claude → Deepgram → Web Speech). → `docs/claude-voice-plan.md`
|
||||
|
||||
### Files panel search
|
||||
|
||||
@@ -884,7 +880,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) 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).
|
||||
⚠️ **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).
|
||||
|
||||
⚠️ **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).
|
||||
|
||||
|
||||
+82
-12
@@ -4,7 +4,9 @@ Codeman can set up a coding model that runs on the Mac the server runs on: no ac
|
||||
no API key, and the session's prompts and files go to a model on this Mac rather than a
|
||||
cloud provider (the coding agent itself may still check for updates). One button on the welcome screen opens a window that
|
||||
shows what fits this Mac, installs the choice and ends on a coding agent session that
|
||||
runs on it.
|
||||
runs on it. The same window then lists the models that are on this Mac with what each takes
|
||||
on disk, adds another one, switches between them and deletes one
|
||||
([The models on this Mac](#the-models-on-this-mac)).
|
||||
|
||||
**Experimental.** The routes and their payloads may change in any release
|
||||
([versioning-policy.md](versioning-policy.md), "Experimental local model routes"). Apple
|
||||
@@ -103,7 +105,9 @@ for a row it does not know. Its answer for each memory size is then pinned in
|
||||
## What an install does, in order
|
||||
|
||||
The person picks a model, presses "Install on this Mac" and confirms the sizes once. The
|
||||
server then runs one job of five steps. The window polls `GET /api/local-model` once a
|
||||
server then runs one job of five steps. The same job adds a second model to an installed one
|
||||
("Add a model"): the runtime is there already, so it downloads the model alone, and the
|
||||
earlier model stays on disk. The window polls `GET /api/local-model` once a
|
||||
second; the job runs in the server, so the window can be closed.
|
||||
|
||||
| Step | What it does |
|
||||
@@ -244,10 +248,13 @@ Switching the setting on also starts that five-minute sweep for every other save
|
||||
existing custom model launch. Before a coding agent is STARTED on that endpoint the server
|
||||
checks, in this order:
|
||||
|
||||
- no job that changes the model in use is running or stopped: until an install or a switch
|
||||
ended, nothing is started ("The model on this Mac is being changed and is not ready");
|
||||
- the runtime child Codeman started is alive (whatever else answers on the port is not
|
||||
ours);
|
||||
- the digest the runtime reports for the installed model is the one stored at install time;
|
||||
- the model asked for is the one that was installed and checked.
|
||||
- the digest the runtime reports for the model in use is the one stored when it was checked;
|
||||
- the model asked for is the model in use. Another model that is on this Mac is not started
|
||||
on the endpoint: switch to it in the window first.
|
||||
|
||||
That check runs wherever a new agent process comes to be on the endpoint: a session created
|
||||
with the model, a model applied to a running session (which restarts its agent), the attach
|
||||
@@ -273,22 +280,76 @@ answer back from a file it writes in its own folder. The skill never installs a
|
||||
answers a confirmation, the runtime still answers one request at a time, and while a worker it
|
||||
opened is live, Stop and Remove are refused until it is closed.
|
||||
|
||||
## The models on this Mac
|
||||
|
||||
The model store can hold several models. **One of them is the model in use**: the one the
|
||||
endpoint record `local-ollama` lists, the runtime is started for and a session can be
|
||||
started on. The runtime holds one model in memory at a time (`OLLAMA_MAX_LOADED_MODELS=1`),
|
||||
so this is what the Mac can really do, and it keeps every rule above: one record, one
|
||||
model, one stored digest per model.
|
||||
|
||||
The installed screen lists what is on this Mac, the model in use first:
|
||||
|
||||
| A row says | It means |
|
||||
| ------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| In use | The model sessions start on |
|
||||
| Ready | Codeman downloaded and checked it. **Use** switches to it |
|
||||
| Not checked | The runtime lists it and Codeman never checked it: a model an earlier Remove kept, or one another program pulled through the runtime's port. It can only be deleted |
|
||||
| Changed | It was checked, and the runtime now reports another digest under its name. It is never started; delete it and install it again |
|
||||
| Missing | It was checked, and the runtime no longer lists it. **Delete** drops the entry |
|
||||
|
||||
Each row shows the context the model was checked with and its size on disk, as the runtime
|
||||
reports it. Under the list is what all downloads take together: the size of the files in
|
||||
`local-model/models/blobs`, so layers two models share are counted once and an interrupted
|
||||
download is included. The window asks the runtime for the list at most every five seconds
|
||||
and measures the store at most every thirty, never on every poll.
|
||||
|
||||
- **Add a model** opens the chooser. A model that is not on this Mac is installed like the
|
||||
first one, with its sizes confirmed; when the job ends it is the model in use, and the
|
||||
earlier model stays in the list. A row that is on this Mac already says so and is switched
|
||||
to, without a download and without a confirm screen.
|
||||
- **Use** switches to a checked model. It is a job of two steps, `verify` and `connect`:
|
||||
the runtime is restarted with the context the model was checked with, the model is loaded
|
||||
and checked again (the tiny chat, the GPU, the tool probe), its digest must still be the
|
||||
stored one (else the switch fails with `model-changed` before the model is loaded), and
|
||||
the endpoint record is rewritten. The coding agent is the one the model was checked with
|
||||
when it is still installed, else the one of the installation.
|
||||
- **Delete** removes one model through the runtime's own delete, after one question in the
|
||||
row. The request carries the model's name, never a path, and the server deletes only a
|
||||
name of its own listing. The model in use cannot be deleted: switch first, or Remove. It
|
||||
needs the running runtime, so a stopped model shows no Delete until Start.
|
||||
|
||||
Adding a model and switching are refused while a session uses the model ("Sessions are
|
||||
still using this model"), like Stop: their coding agents were started with the other model's
|
||||
name, and the runtime would swap models on every request.
|
||||
|
||||
While a job that started from an installed model runs, or after it stopped (paused or
|
||||
failed), no session is started: the checks above want a finished installation, and the
|
||||
refusal says so (`job-unfinished`). The window
|
||||
then lists the checked models with **Use**, which is the way back to the earlier one. Going
|
||||
back deletes what the stopped job left of a model that never passed its check, the way a new
|
||||
install does; a checked model is never deleted that way.
|
||||
|
||||
## Stop, Start, Remove
|
||||
|
||||
- **Stop** stops the runtime and keeps it from starting at the next boot. **Start** undoes
|
||||
that.
|
||||
that, and so does a switch to another model or the install of one: the job starts the
|
||||
runtime, and the model it ends on is in use.
|
||||
- **Remove** stops the runtime, removes the endpoint record, switches the setting off again
|
||||
only when this feature switched it on and no other endpoint is saved, and deletes the
|
||||
runtime and its private home. The downloaded model is deleted only when asked. The coding
|
||||
agent stays. While a live session uses the model, Remove answers with the session names
|
||||
instead.
|
||||
runtime and its private home. The downloaded models (all of them) are deleted only when
|
||||
asked. The coding agent stays. While a live session uses the model, Remove answers with
|
||||
the session names instead.
|
||||
- **Models a Remove kept** are not lost space: with nothing installed the window says how
|
||||
much they still take and offers "Delete them", and after a new install they show in the
|
||||
list as "Not checked", each with its own Delete.
|
||||
|
||||
## Where everything is
|
||||
|
||||
All under the data directory (`~/.codeman`, or the instance's own):
|
||||
|
||||
```
|
||||
local-model.json the state file (mode 0600)
|
||||
local-model.json the state file (mode 0600), with the list of checked models
|
||||
local-model/
|
||||
runtime/ollama-0.40.15.tgz.partial the runtime archive while it downloads
|
||||
runtime/ollama-0.40.15/ the unpacked runtime (about 579 MB)
|
||||
@@ -313,9 +374,12 @@ in App Settings.
|
||||
| ------------ | ------------------------------------------------------------- | ---------------- | -------------------------------------- |
|
||||
| `idle` | stays idle | refused | install |
|
||||
| `installing` | becomes `paused` at the same step | becomes `paused` | cancel |
|
||||
| `paused` | stays paused | refused | resume, install, remove |
|
||||
| `failed` | stays failed, same error | refused | resume, install, remove |
|
||||
| `installed` | stays installed; the runtime is started unless it was stopped | refused | start a session, stop or start, remove |
|
||||
| `paused` | stays paused | refused | resume, install, use, delete a model, remove |
|
||||
| `failed` | stays failed, same error | refused | resume, install, use, delete a model, remove |
|
||||
| `installed` | stays installed; the runtime is started unless it was stopped | refused | start a session, stop or start, install (add a model), use, delete a model, remove |
|
||||
|
||||
`use` and "delete a model" are listed only while a row of the list offers them, and in
|
||||
`idle` "remove" is offered only while the model store still holds something.
|
||||
|
||||
One job at a time. A job a server restart interrupted comes back paused and never continues
|
||||
without a click. Cancel interrupts the step in flight (the download, the pull, the request,
|
||||
@@ -357,6 +421,12 @@ or `python3`.
|
||||
on a Mac. "Recommended" appears only for the pair we ran, on the Mac we ran it on.
|
||||
- **The model stays in memory for ten minutes** after the last request, then the runtime
|
||||
unloads it.
|
||||
- **One model runs at a time.** Several can be on disk; the runtime holds one, and a switch
|
||||
restarts it and loads the other model (seconds to a minute). Close the sessions on the
|
||||
model first: the window refuses the switch while one is open.
|
||||
- **The disk figures are the runtime's.** A row's size is what the runtime reports for the
|
||||
model, and the total is the size of its blob files. Neither was compared with Finder on a
|
||||
Mac yet.
|
||||
|
||||
## Keeping the catalogue current
|
||||
|
||||
|
||||
@@ -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`, `lastUsedCase`, `lastPickedCase` and `caseSettings` (`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` and `lastUsedCase` (`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 |
|
||||
|
||||
|
||||
@@ -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` and `lastPickedCase`, `intents.json`
|
||||
minutes), `users.json` `lastLoginAt`, `settings.json` `lastUsedCase`, `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
|
||||
|
||||
@@ -234,6 +234,11 @@ Good to know:
|
||||
but it is changed and removed in the local model window, not here.
|
||||
- Codeman starts the runtime with the server and stops it with it. **Stop** keeps it from
|
||||
starting next time.
|
||||
- More than one model can be on the Mac, and one of them runs at a time. Once a model is
|
||||
installed the window lists the models with what each takes on disk: **Add a model**
|
||||
downloads another one, **Use** switches to a model that is already there (nothing is
|
||||
downloaded; close the sessions on the model first), and **Delete** removes one, after one
|
||||
question. The model in use cannot be deleted: switch first, or use **Remove**.
|
||||
- **Remove** deletes the runtime and the endpoint, and the model too if you tick that. The
|
||||
coding agent stays installed.
|
||||
- If the model on disk is no longer the one that was installed, Codeman refuses to start a
|
||||
|
||||
@@ -78,26 +78,6 @@ sequences, so word jumps and history keys keep working.
|
||||
| **Deepgram** | A Deepgram API key. | Nova-3, with automatic silence detection. |
|
||||
| **Web Speech** | Nothing. | Browser-provided, quality varies. |
|
||||
|
||||
### Thinking pauses
|
||||
|
||||
In **App Settings → Voice → Stop after silence**, choose 3, 6, 15 or 30 seconds,
|
||||
or **Manual** to keep listening until you tap the mic again (`Ctrl+Shift+V`).
|
||||
**Provider default** uses 6 seconds for Claude and 3 seconds for Deepgram or Web
|
||||
Speech. Final transcript events no longer stop Deepgram or Web Speech immediately:
|
||||
they wait for the configured silence deadline. The timeout counts from the latest
|
||||
transcript update. A browser can still end its native recognition run; extended
|
||||
and Manual dictation restart it while preserving the text and silence deadline.
|
||||
A silent run that lasts its full length never ends one, so 30 seconds and Manual
|
||||
survive long pauses. Connection failures, microphone errors, or a recognizer that
|
||||
keeps ending within a second of starting without hearing anything can still end
|
||||
a recording.
|
||||
|
||||
On Chrome for Android, live words replace earlier partial results without repeating
|
||||
them in the input. The browser microphone bars indicate speech activity; streaming
|
||||
providers show audio levels. After Stop, dictation briefly waits for the final
|
||||
words before inserting text. Reconnecting preserves the available words and resets
|
||||
the mic so another recording can start.
|
||||
|
||||
### Dictating through your Claude login
|
||||
|
||||
Off by default; enable it in **App Settings → Voice**.
|
||||
|
||||
@@ -248,19 +248,9 @@ A small native app that opens Codeman in its own window. Its first screen has tw
|
||||
runs the installer on that machine over SSH.
|
||||
|
||||
On a MacBook Pro with a PROLED XDR display, whose lid logo lights up,
|
||||
**View > Blink Apple Logo** makes the logo show what your agents are doing: it breathes while
|
||||
they work, blinks when one waits for you and flashes three times when one needs permission.
|
||||
Each state can take another style under **View > Apple Logo Blink Styles**. It is off until
|
||||
you turn it on, and the menu only exists on such a Mac.
|
||||
|
||||
**Codeman > Keep This Mac Awake** stops the Mac from going to sleep while your agents work
|
||||
on it (**While Agents Work**, plus 5 minutes after the last one stops), or for as long as
|
||||
the app is open (**Always**). It is off until you choose, only acts for a Codeman running
|
||||
on this Mac, needs no password, and ends when the app quits. On a MacBook it pauses on
|
||||
battery unless you untick **Only When Plugged In**, and **With the Lid Closed…** keeps a
|
||||
closed MacBook awake too: ticking it asks for your administrator password to install a
|
||||
small helper, unticking it asks again and removes the helper, and it never acts on
|
||||
battery.
|
||||
**View > Blink Apple Logo** blinks the logo by what your agents are doing, with a style of
|
||||
your own for working, for waiting for you and for needing permission. All three are off
|
||||
until you choose, and the menu only exists on such a Mac.
|
||||
|
||||
### First launch
|
||||
|
||||
|
||||
@@ -247,9 +247,8 @@ Discord group for the two-way bot (token, IDs, thread and ping options; see
|
||||
### Voice
|
||||
|
||||
Active provider and the engine behind it, insert mode, language, domain keywords to bias
|
||||
recognition, **Stop after silence** (provider default, 3, 6, 15 or 30 seconds, or Manual),
|
||||
the Deepgram API key, and the opt-in switch for transcribing through this server's Claude
|
||||
login, with its live credential status. See
|
||||
recognition, the Deepgram API key, and the opt-in switch for transcribing through this
|
||||
server's Claude login, with its live credential status. See
|
||||
[Input And Voice](Input-And-Voice).
|
||||
|
||||
### Shortcuts
|
||||
@@ -259,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. **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
|
||||
Cloudflare tunnel controls including the tunnel URL. 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.
|
||||
|
||||
@@ -19,13 +19,6 @@ not finish shows **Not fully scanned** rather than looking empty. A folder that
|
||||
such as a network mount that went away, is skipped rather than waited on and is marked the same way,
|
||||
so a search can miss what is inside it. The panel's Refresh button tries it again.
|
||||
|
||||
Each file row has a download arrow and a × that deletes the file after you confirm. Both show
|
||||
when you point at the row, and always on a touch screen. Delete removes one file at a time,
|
||||
never a folder, and refuses a symbolic link (deleting one from here would remove the file it
|
||||
points to), anything under `.git`, and the sensitive files the viewer will not serve. It is not
|
||||
offered for remote (SSH) sessions or for tmux sessions opened from outside Codeman. Deleting
|
||||
from search results takes the row out of the results.
|
||||
|
||||
It renders what it can:
|
||||
|
||||
| Kind | Behaviour |
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "aicodeman",
|
||||
"version": "1.50.1",
|
||||
"version": "1.50.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "aicodeman",
|
||||
"version": "1.50.1",
|
||||
"version": "1.50.0",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "aicodeman",
|
||||
"version": "1.50.1",
|
||||
"version": "1.50.0",
|
||||
"description": "Self-hosted mission control for AI coding agents: run Claude Code, Codex, OpenCode, Gemini, DeepSeek, Grok and more 24/7 in tmux, from any device.",
|
||||
"mcpName": "io.github.Ark0N/codeman",
|
||||
"type": "module",
|
||||
|
||||
@@ -50,7 +50,6 @@ objc2-foundation = { version = "0.3.2", default-features = false, features = [
|
||||
"NSGeometry",
|
||||
"NSObject",
|
||||
"NSOperation",
|
||||
"NSProcessInfo",
|
||||
"NSRange",
|
||||
"NSSet",
|
||||
"NSString",
|
||||
|
||||
+35
-140
@@ -40,8 +40,7 @@ cannot do.
|
||||
| **Links** | On Codeman's own pages (the dashboard and session windows), a link to another site opens in your default browser, also on `⌘`-click or a middle click. Pages can send at most 3 links to the browser at once, then 1 per second. Inside a rendered HTML preview or a web tab, a plain link to another site does nothing, while one that asks for a new window (`target="_blank"`, `window.open`) opens in your default browser within the same budget (see Known limits). |
|
||||
| **Downloads** | Codeman's Download links, and downloads Codeman opens in a new window, save the file to `~/Downloads` whatever its type, and a toast in Codeman names the file. Files the web view cannot display are saved the same way. |
|
||||
| **A page that stops** | When the web content process of a window ends (a WebKit crash, or macOS taking the process back under memory pressure), the window loads its page again instead of staying blank. A second time within 30 seconds, the main window goes back to the connect screen and a pop-out closes, so a page that cannot stay up never becomes a reload loop. |
|
||||
| **Apple logo** | On a MacBook Pro with a PROLED XDR display, the lit Apple logo on the lid says what the agents are doing. **View > Blink Apple Logo** is the one switch, off until you turn it on. The more the logo moves, the more it needs you: it breathes (a slow fade out and back in) while agents work, blinks every two seconds for a yellow tab and flashes three times, then rests, for a red one. Each state can take another style under **View > Apple Logo Blink Styles**: Off, Breathe, Calm, Slow, Wink, Heartbeat, Fast, or Alarm. Picking a style turns the switch on, and turning the switch on with every state off brings back the recommended three (Breathe, Calm, Alarm). When several states hold, the most urgent one with a style shows: red, then yellow, then work. Styles chosen in 1.50.0 are kept, with the switch on if any of them was. The menu only exists on a Mac with that logo. No password is needed. Shell tabs do not count. The app asks the server every two seconds, and a change has to hold for three seconds before the logo follows it, so an agent between two turns does not flicker it. It keeps working while the window is hidden, and with any Codeman server. The logo moves on its own hardware fades, the ones [LogoLight](https://github.com/Ark0N/LogoLight) uses, so a blink writes no setting, and a logo you switched off yourself stays off. LogoLight (1.3 or later) steps aside while Codeman shows its agents on the logo, and takes the logo back afterwards, with whatever it showed before; while an older LogoLight runs, Codeman leaves the logo to it. While the app is closed nothing blinks. With the lid closed it just goes on while the Mac stays awake on its power adapter (**Keep This Mac Awake > With the Lid Closed…**, or an external display): macOS keeps the logo dark under a closed lid, so the app tells the logo the lid is open and the keyboard that it is closed, which stays dark, as LogoLight's *Lit with Lid Closed* does. The light sensor is covered then, so the logo glows as it would in a dark room. The line under **Apple Logo Blink Styles** says what the closed lid did last (blinking, or why not). On battery, and while the display only sleeps with the lid open, the blink pauses and writes nothing. |
|
||||
| **Keep awake** | **Codeman > Keep This Mac Awake** keeps the Mac from going to sleep while Codeman's agents work on it: **While Agents Work** (an agent at work, and for 5 minutes after the last one stopped, so the gap between two turns or a reply from your phone does not let it drop off), or **Always** (while the app is open). Off until you choose. Only for a Codeman on this Mac (`127.0.0.1`, `localhost`, or one of this Mac's own addresses); for a server on another machine the menu says so. The display still sleeps. No password is needed for this part: the app holds a power assertion of its own, which macOS shows in Activity Monitor and in `pmset -g assertions` as *Codeman keeps this Mac awake while its agents work*, and which ends with the app, a crash or a Force Quit included. On a MacBook, **Only When Plugged In** (on by default) pauses it on battery, and **With the Lid Closed…** keeps a closed MacBook awake as well: nothing an app holds can do that, so ticking it asks for your administrator password and installs a small helper (see Decisions), and unticking it asks again and removes the helper. The lid is never kept awake on battery, whatever the settings, and while the lid is covered *Sleep* in the Apple menu does nothing. A line at the bottom of the menu says what it does right now. It lives in the app, so it ends when the app quits: Codeman keeps running as a LaunchAgent, but nothing keeps the Mac awake then. That is why, while it watches a Codeman on this Mac, closing the Codeman window hides the app instead of quitting it, as Cmd+H does, also while it is paused (on battery, say), since it carries on by itself: a click on its Dock icon brings it back, and only Cmd+Q (or Quit from the Dock) ends it. With no server open, or one on another machine, closing the window quits. |
|
||||
| **Apple logo** | On a MacBook Pro with a PROLED XDR display, the lit Apple logo on the lid says what the agents are doing, with a blink style of your own for each state: **View > Blink Apple Logo > While Working**, **Waiting for You (Yellow)** and **Needs Permission (Red)**. Off, Calm, Slow, Wink, Heartbeat, Fast, or Alarm, three sharp flashes for a state that needs you. All three are off until you choose. When several states hold, the most urgent one with a style shows: red, then yellow, then work. A style chosen in an earlier version stays as the one for work, and yellow and red start as Wink and Alarm. The menu only exists on a Mac with that logo. No password is needed. Shell tabs do not count. The app asks the server every two seconds, and a change has to hold for three seconds before the logo follows it, so an agent between two turns does not flicker it. It keeps working while the window is hidden, and with any Codeman server. A logo you switched off yourself stays off, and if the setting changes while a blink runs, the blink ends and leaves it as it found it. While the app is closed nothing blinks, and macOS keeps the logo dark while the display sleeps or the lid is closed, so the blink pauses then and writes nothing. |
|
||||
| **Window** | Size, position and full-screen state are remembered. A saved window that hangs off the screen it opens on (it was last on a larger display that is gone, say) is pulled back on and shrunk to fit; one whose title bar is on no screen at all opens at the default size instead. Clicking the Dock icon brings back a minimized window. |
|
||||
|
||||
## Build (on a Mac)
|
||||
@@ -148,30 +147,6 @@ classes of wry's own delegates, so wry stays an unmodified crates.io dependency.
|
||||
- `codeman web -d` gets the port and base path as separate arguments, never as
|
||||
shell text.
|
||||
|
||||
**Keeping a closed MacBook awake takes a root helper, installed on request.**
|
||||
macOS sleeps on lid close whatever an app asks: `caffeinate` and every power
|
||||
assertion stop idle sleep only. `pmset -a disablesleep 1` keeps a closed MacBook
|
||||
awake, but it is a machine-wide root setting with no owner process, so nothing
|
||||
would undo it after a crash. So ticking *With the Lid Closed…* installs a
|
||||
LaunchDaemon (`com.getcodeman.keepawake`) that runs `macos/keep-awake-lid.sh`
|
||||
every 20 seconds. The script sets `disablesleep` only while a request file next
|
||||
to `desktop.json` is younger than 2 minutes, is not a symlink, names a live
|
||||
process of the file's owner, and `pmset -g batt` says the Mac runs on its power
|
||||
adapter. The app writes its own pid there and rewrites it every 30 seconds while
|
||||
it wants the lid covered, so an app that quits or crashes is a dead pid and the
|
||||
lid is released at the next run. The script undoes only a `disablesleep` it set
|
||||
itself (a marker file), never an administrator's own. The app writes the script
|
||||
and the plist itself, as root, straight into `/Library/Application
|
||||
Support/Codeman` and `/Library/LaunchDaemons` from the copies built into it
|
||||
(base64 in the root command, so nothing user-writable is copied and no file
|
||||
content needs quoting), behind macOS's administrator prompt (`do shell script
|
||||
... with administrator privileges` through `osascript`, so the event loop keeps
|
||||
running while the prompt is open). The idle-sleep part needs none of this: an
|
||||
`NSProcessInfo` activity with `IdleSystemSleepDisabled`, released by the kernel
|
||||
with the process. While the app watches for work but holds nothing, a second
|
||||
kind of activity keeps App Nap from slowing its look at the server, and still
|
||||
allows sleep.
|
||||
|
||||
**The app runs the installer itself; Terminal is the fallback.** `install.sh
|
||||
--no-admin` puts Node.js and tmux in the home folder, so a This Mac install needs
|
||||
no password and therefore no terminal, and the two questions only a person can
|
||||
@@ -250,93 +225,31 @@ If that probe is refused or rate limited, the connect screen comes back; the
|
||||
app does not send the refused login again. Keeping no copy of the login in
|
||||
WebKit has a cost here: after a server restart, each request that arrives
|
||||
without a cookie gets a 401 before the
|
||||
login goes out, and those count until on**The logo moves on its own hardware fades, from a child process, through CoreBrightness.**
|
||||
Apple documents one control for the logo ([support.apple.com/en-us/149248](https://support.apple.com/en-us/149248)):
|
||||
`sudo defaults write com.apple.CoreBrightness LEBacklightMode -int 0`, an on/off setting that
|
||||
`corebrightnessd` keeps in root's preferences. A process of the logged-in user can reach the
|
||||
daemon without an administrator through `BrightnessSystemClient` in the private CoreBrightness
|
||||
framework, and 1.50.0 blinked by switching that setting there. The blink now uses two other
|
||||
properties of the same client, which [LogoLight](https://github.com/Ark0N/LogoLight) (MIT, its
|
||||
`docs/corebrightnessd.md`) measured on the real logo and `src/logo.rs` reimplements:
|
||||
`LEPWMFactorFadeDuration` sets how long the next fade takes, and `DisplayLogicalState` 1
|
||||
(Blank) fades the logo to dark and 2 (Unblank) back to lit, on the LED's own spring curve.
|
||||
Nothing of that is saved, so a blink writes no setting and leaves nothing behind a restart;
|
||||
`LEBacklightMode` is only read.
|
||||
login goes out, and those count until one succeeds. Many windows reconnecting
|
||||
at once can push a request past 10, and that request then fails with a 429
|
||||
instead of being answered. A refusal that lands while you are in another app
|
||||
does not bring Codeman forward: the connect screen loads where the window is,
|
||||
and the Dock icon bounces.
|
||||
|
||||
The daemon decides the rest. Writing the direction the logo already heads in is ignored, and
|
||||
the state cannot be read back, so the child keeps track of it and, when it does not know,
|
||||
first turns the logo round with a fade too slow to see. The keyboard backlight follows
|
||||
`DisplayLogicalState` too, so after every Blank the keyboards alone are told Unblank
|
||||
(`setProperty:withKey:keyboardID:`), only while the built-in display is awake
|
||||
(`CGDisplayIsAsleep`); when it sleeps they are told Blank, and Unblank when it wakes. A rise
|
||||
from exactly dark waits for the display to show a frame, up to half a second with nothing
|
||||
changing on screen. So the short dark beats (Alarm's, Heartbeat's, Wink's, Fast's) turn round
|
||||
while their fall is still under way and never quite reach dark, and a long one (Calm's,
|
||||
Slow's, the breath's rest) times its light from when the rise starts.
|
||||
**The logo blinks from a child process, through CoreBrightness.** Apple documents
|
||||
one control for the logo ([support.apple.com/en-us/149248](https://support.apple.com/en-us/149248)):
|
||||
`sudo defaults write com.apple.CoreBrightness LEBacklightMode -int 0`. Doing that
|
||||
for every blink would need a root helper and an administrator password. The same
|
||||
setting is also a property of `BrightnessSystemClient` in the private
|
||||
CoreBrightness framework, and a process of the logged-in user can set it there,
|
||||
which is what `src/logo.rs` does. Measured on a Mac17,13 with macOS 27.2 from an
|
||||
ad-hoc signed binary: `corebrightnessd` logs the logo dark about a millisecond
|
||||
after the write, with no fade, and the NVRAM variable `bklb-backlight-enabled`
|
||||
keeps its value while the logo is dark.
|
||||
|
||||
With the lid closed the daemon tells every module so (`ClamshellUpdate` 1), and the logo's
|
||||
target is then 0 whatever `DisplayLogicalState` says. A client's `ClamshellUpdate` reaches the
|
||||
same modules, and LogoLight measured on the real MacBook what follows from that, first with an
|
||||
external display and, for its 1.3.1, on a Mac kept awake with none:
|
||||
- Kept awake with the lid closed and no other display, the built-in display stays online and
|
||||
`CGDisplayIsAsleep` says 0. So the lid sensor (`AppleClamshellState` on `IOPMrootDomain`)
|
||||
decides that the logo is out of sight, not the display list.
|
||||
- Once the daemon has handled the lid (`CBClamshellState` reads 1, a moment after the sensor),
|
||||
`ClamshellUpdate` 0 relights the logo and fades work as with the lid open. The child then
|
||||
writes `ClamshellUpdate` 1 to each keyboard alone, which stays dark, and to each display in
|
||||
`CBDisplayList` alone (`setProperty:withKey:andDisplay:`): told the lid is open, True Tone
|
||||
adapts an external display to the covered light sensor and tints it warm and dim.
|
||||
- The daemon sends the real lid state again when a display or a keyboard comes up. Every beat
|
||||
ends lit, so a logo found dark at the start of the next one is told again.
|
||||
- `DisplayPowerStateEvent` is never written. A client's DidSleep keeps the logo dark, with the
|
||||
lid open too, until someone writes DidWake.
|
||||
|
||||
The child does this only on the power adapter (on battery a closed MacBook sleeps, and a dark
|
||||
wake must not light the logo in a bag) and only with every keyboard reachable. The first rise
|
||||
has to move the logo within 1.5 s. If it does not, the logo is told again, three times in a
|
||||
row and then every half minute, and the menu line says it stayed dark. Ending a blink with the
|
||||
lid closed hands the logo back to normal (the automatic duration, then Unblank, which a held
|
||||
level needs) and then writes `ClamshellUpdate` 1, since normal for a closed lid is dark; the
|
||||
marker covers a child killed in between. When the lid opens, the daemon tells every module
|
||||
itself and nothing is written. What the closed lid did last (blinking, stayed dark, nothing to
|
||||
show with no agent at work, not tried and why, or that the Mac went to sleep under it, which
|
||||
the child sees as wall-clock time that passed while its monotonic clock stood still) goes to a
|
||||
file next to `desktop.json` (`logo-lid`, with the readings behind it on its second line), and
|
||||
the app shows its first line as **Lid Closed: …** under View > Apple Logo Blink Styles, or
|
||||
says nothing watches the lid while no child runs.
|
||||
|
||||
Whether the Mac stays awake under the lid is not the logo's business: Keep This Mac Awake's
|
||||
lid helper does that (`pmset disablesleep`, which holds the Mac at full wake), or LogoLight's
|
||||
own Lit with Lid Closed, or an external display. An assertion against system sleep alone would
|
||||
not do: LogoLight saw the Mac enter a dark wake with it, where the logo stays dark.
|
||||
|
||||
LogoLight drives the logo the same way, so the two would fight over every fade. The marker the
|
||||
child keeps next to the app's settings while it moves the logo (`logo-fade`, which also lets the
|
||||
next launch put back a logo a killed child left dark) holds the child's process ID, goes down
|
||||
right before the first write and comes off once the logo is back to normal. A LogoLight that says
|
||||
`LogoLightStepsAsideForCodeman` in its Info.plist (read from the running app's bundle, by its
|
||||
process ID) looks at that file every second and pauses while it names a running
|
||||
`codeman-desktop`, so the child waits two seconds after writing it before its first fade.
|
||||
LogoLight takes the logo back only once the file has been gone for a second and a half, so a
|
||||
blink that starts within a second of the last one's end (work, then a permission) does not
|
||||
wait again. An older LogoLight is left the logo, as before. LogoLight ships its side since
|
||||
1.3.0. With the lid closed, the two tell the logo the same thing (the lid is open), so a
|
||||
LogoLight that lights the logo under the lid and a blink do not undo each other; when a blink
|
||||
ends there the logo goes dark, and LogoLight lights it again as it takes it back.
|
||||
|
||||
The app must not end with the logo dark, and the release profile has `panic = "abort"`, so no
|
||||
cleanup code of the app can be relied on. So all of it runs as this same binary with
|
||||
`--logo-blink`, for as long as the switch is on and a server is open, tied to the app by
|
||||
nothing but a pipe: when the pipe closes, however the app ended, it puts the logo back (lit,
|
||||
with the daemon's automatic fade duration) and exits. Switching it off closes the same pipe,
|
||||
so the path a crash takes is the one used every day. A marker file (`logo-fade`, next to
|
||||
`desktop.json`) covers what the child cannot survive itself: at the next launch the app puts
|
||||
back a logo a blink left dark, and with 1.50.0's marker (`logo-blink`) it switches back on a
|
||||
logo that version left off. The framework is loaded at run time and never linked, and
|
||||
`LEBacklightMode` reading nil (a Mac mini answers nil) is what hides the option, so a macOS
|
||||
that drops it loses this feature and nothing else.
|
||||
|
||||
e child cannot survive itself:
|
||||
The setting is system-wide and survives a reboot, so the app must not end with the
|
||||
logo dark, and the release profile has `panic = "abort"`, so no cleanup code of the
|
||||
app can be relied on. So all of it runs as this same binary with
|
||||
`--logo-blink`, for as long as any style is chosen and a server is open, tied to the app
|
||||
by nothing but a pipe: when the pipe closes, however the app ended, it puts the logo
|
||||
back and exits. Switching it off closes the same pipe, so the path a crash
|
||||
takes is the one used every day. A marker file
|
||||
(`logo-blink`, next to `desktop.json`) covers what the child cannot survive itself:
|
||||
at the next launch the app puts back a logo a blink left dark. The framework is
|
||||
loaded at run time and never linked, and `LEBacklightMode` reading nil (a Mac mini
|
||||
answers nil) is what hides the option, so a macOS that drops it loses this feature
|
||||
@@ -395,35 +308,17 @@ crosses, so the login is never on a command line.
|
||||
look as a failed login, like any look at a server the app has no login for.
|
||||
*Check again* asks again.
|
||||
- **The logo blink rests on a private macOS interface.** Apple documents the
|
||||
on/off setting, not the fades the app uses, so a macOS update can take them
|
||||
away. The menu disappears if `LEBacklightMode` goes; if only the fade keys
|
||||
change, the logo stops moving. A long dark beat (*Calm*, *Slow*, the rest in
|
||||
*Breathe*) can be up to half a second longer than its style says, while the
|
||||
display waits for a frame before the logo rises. Every Blank dims the
|
||||
keyboard backlight a little before it is told to stay lit. A child killed
|
||||
outright (SIGKILL) leaves the logo where it was, perhaps dark, until the
|
||||
display next sleeps and wakes or the app next starts. Another app that drives
|
||||
the logo the same way and is not LogoLight is not recognised, and the two
|
||||
fight. The handover with a LogoLight that steps aside was checked against a
|
||||
stand-in process carrying LogoLight 1.3.1's Info.plist and against LogoLight's
|
||||
own tests, and ran in that release candidate next to LogoLight 1.3.1; its
|
||||
timing on a real logo was not measured. `api/sessions` does not say which turns are Codeman's own, so with
|
||||
prompt-cache keep-warm on, a keep-alive turn blinks too.
|
||||
- **Keeping the lid awake has not been seen on a real closed MacBook yet.**
|
||||
The helper ran every case against a stand-in `pmset`, the install and removal
|
||||
commands ran in a temporary folder with stand-ins for `chown` and `launchctl`,
|
||||
and the power assertion was measured on a Mac mini (it appears with its name
|
||||
while agents work, and goes with a clean end and with a SIGKILL). Whether a
|
||||
closed MacBook goes to sleep at once when the helper lets go (the app quit, an
|
||||
agent finished, the adapter came out) has not been watched. The helper is
|
||||
machine-wide: on a Mac where two users install it, the last install wins.
|
||||
- **Blinking with the lid closed was seen on one MacBook Pro so far** (macOS
|
||||
27.2, on power, a release candidate of 1.50.1 on 2026-11-10; the method is
|
||||
LogoLight's, which its author measured for LogoLight 1.3.1). Nothing was
|
||||
measured in that run, it was watched. Not checked, by LogoLight or here: the
|
||||
built-in display's brightness when the lid opens again, and an Apple display
|
||||
with its own light sensor. If the logo does not move under a closed lid, the
|
||||
menu line says "stayed dark".
|
||||
setting, not the way the app reaches it, so a macOS update can take it away;
|
||||
the menu then disappears. Every change of the logo writes `LEBacklightMode`,
|
||||
from about once every two seconds (*Slow*, *Wink*) to about three times a
|
||||
second (*Fast*), which `corebrightnessd` keeps in root's preferences. The
|
||||
setting is on or off, so these styles are rhythms of hard changes. After a
|
||||
blink the key stays there rather than being absent as on a fresh Mac. That
|
||||
reads the same to macOS; `sudo defaults delete com.apple.CoreBrightness
|
||||
LEBacklightMode` removes it. After a power cut in a
|
||||
dark phase the logo stays dark until the app next starts. `api/sessions` does
|
||||
not say which turns are Codeman's own, so with prompt-cache keep-warm on, a
|
||||
keep-alive turn blinks too.
|
||||
- **Self-signed HTTPS is refused.** A server started with `codeman web --https`
|
||||
uses a self-signed certificate, and WKWebView rejects it (wry has no trust
|
||||
override). Use `http://127.0.0.1:3000` for a server on this Mac. For another
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
#!/bin/bash
|
||||
# keep-awake-lid.sh: the root helper behind "Keep This Mac Awake > Also With the Lid
|
||||
# Closed" in the Codeman app for Mac.
|
||||
#
|
||||
# The app keeps the Mac from idle sleep on its own, with no root (a power assertion).
|
||||
# Closing the lid is another matter: only `pmset -a disablesleep 1` keeps a closed
|
||||
# MacBook awake, and that is a machine-wide root setting with no owner process. So this
|
||||
# helper, run by the com.getcodeman.keepawake LaunchDaemon every 20 seconds, applies it
|
||||
# while the app asks for it and undoes it when the app stops asking:
|
||||
#
|
||||
# - The app writes its pid to the request file, and rewrites it every 30 seconds, while
|
||||
# it wants the lid covered.
|
||||
# - The request counts only while the file is fresh (under 2 minutes old), is not a
|
||||
# symlink, its pid is alive, that process belongs to the file's owner, and the Mac
|
||||
# runs on its power adapter. An app that quits or crashes is a dead pid, so the lid
|
||||
# is released at the next run.
|
||||
# - Never on battery, whatever the app says: a closed MacBook that cannot sleep in a
|
||||
# bag only heats up and drains.
|
||||
# - It undoes only a disablesleep it set itself (the .owned file), so an
|
||||
# administrator's own `pmset -a disablesleep 1` is never switched off.
|
||||
#
|
||||
# The app writes this file to a ROOT-OWNED path itself, behind macOS's administrator
|
||||
# prompt; a root daemon must never run a file a user can edit.
|
||||
#
|
||||
# Usage: keep-awake-lid.sh <request-file>
|
||||
# Test hooks (set only by the test suite; launchd passes no environment of the user's):
|
||||
# CODEMAN_KEEPAWAKE_PMSET, CODEMAN_KEEPAWAKE_STATE_DIR.
|
||||
#
|
||||
# bash 3.2 (what macOS ships) and BSD tools only.
|
||||
|
||||
set -u
|
||||
|
||||
REQ="${1:?usage: keep-awake-lid.sh <request-file>}"
|
||||
PMSET="${CODEMAN_KEEPAWAKE_PMSET:-/usr/bin/pmset}"
|
||||
STATE_DIR="${CODEMAN_KEEPAWAKE_STATE_DIR:-/Library/Application Support/Codeman}"
|
||||
OWNED="$STATE_DIR/keep-awake.owned"
|
||||
|
||||
want=0
|
||||
# -L: refuse a symlink (root must not be steered to read some other file).
|
||||
# find -mmin -2: modified within the last 2 minutes (BSD and GNU find both support it).
|
||||
if [ -f "$REQ" ] && [ ! -L "$REQ" ] && [ -n "$(find "$REQ" -mmin -2 2>/dev/null)" ]; then
|
||||
pid=$(head -c 32 "$REQ" 2>/dev/null | tr -dc '0-9')
|
||||
if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then
|
||||
file_uid=$(ls -ln "$REQ" 2>/dev/null | awk '{print $3}')
|
||||
pid_uid=$(ps -o uid= -p "$pid" 2>/dev/null | tr -d ' ')
|
||||
if [ -n "$file_uid" ] && [ "$file_uid" = "$pid_uid" ]; then
|
||||
want=1
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# The power adapter, or nothing: an unreadable answer counts as battery.
|
||||
if [ "$want" = "1" ] && ! "$PMSET" -g batt 2>/dev/null | grep -q "'AC Power'"; then
|
||||
want=0
|
||||
fi
|
||||
|
||||
current=$("$PMSET" -g 2>/dev/null | awk '/SleepDisabled/ {print $2; exit}')
|
||||
[ -n "$current" ] || current=0
|
||||
|
||||
if [ "$want" = "1" ]; then
|
||||
# Already 1 without our marker means an administrator set it: leave it and do not
|
||||
# claim it, so releasing later never switches their setting off.
|
||||
if [ "$current" != "1" ]; then
|
||||
if "$PMSET" -a disablesleep 1; then
|
||||
mkdir -p "$STATE_DIR" && : > "$OWNED"
|
||||
fi
|
||||
fi
|
||||
elif [ -f "$OWNED" ]; then
|
||||
if [ "$current" = "1" ]; then
|
||||
"$PMSET" -a disablesleep 0 || exit 1
|
||||
fi
|
||||
rm -f "$OWNED"
|
||||
fi
|
||||
exit 0
|
||||
@@ -3,7 +3,6 @@
|
||||
//! (`~/Library/Application Support/Codeman/desktop.json` on macOS). Passwords
|
||||
//! never go here; they live in the Keychain (see `credentials`).
|
||||
|
||||
use crate::keep_awake;
|
||||
use crate::logo::BlinkStyles;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fs;
|
||||
@@ -45,16 +44,11 @@ pub struct Config {
|
||||
pub auto_connect: bool,
|
||||
/// Always open in full screen (otherwise the last state is restored).
|
||||
pub start_fullscreen: bool,
|
||||
/// Whether the lit Apple logo shows what the agents are doing, and its style
|
||||
/// for each state (logo.rs). The menu only shows on a Mac that has one. A
|
||||
/// value this version does not know reads as off: a bad style must not make
|
||||
/// the whole file unreadable.
|
||||
/// What the lit Apple logo does for each state of the agents (logo.rs). The
|
||||
/// menu only shows on a Mac that has one. A value this version does not know
|
||||
/// reads as off: a bad style must not make the whole file unreadable.
|
||||
#[serde(deserialize_with = "lenient_blink")]
|
||||
pub logo_blink: BlinkStyles,
|
||||
/// Keep This Mac Awake (keep_awake.rs): when, only on the power adapter, and
|
||||
/// with the lid closed too. Read field by field, like `logo_blink`.
|
||||
#[serde(deserialize_with = "lenient_keep_awake")]
|
||||
pub keep_awake: keep_awake::Settings,
|
||||
/// Anything that is not a whole frame reads as None: a bad frame must not
|
||||
/// make the whole file unreadable, which would cost the server list.
|
||||
#[serde(deserialize_with = "lenient_window")]
|
||||
@@ -70,10 +64,6 @@ fn lenient_blink<'de, D: serde::Deserializer<'de>>(d: D) -> Result<BlinkStyles,
|
||||
Ok(BlinkStyles::read(&serde_json::Value::deserialize(d)?))
|
||||
}
|
||||
|
||||
fn lenient_keep_awake<'de, D: serde::Deserializer<'de>>(d: D) -> Result<keep_awake::Settings, D::Error> {
|
||||
Ok(keep_awake::Settings::read(&serde_json::Value::deserialize(d)?))
|
||||
}
|
||||
|
||||
impl Default for Config {
|
||||
fn default() -> Self {
|
||||
Config {
|
||||
@@ -81,7 +71,6 @@ impl Default for Config {
|
||||
auto_connect: true,
|
||||
start_fullscreen: false,
|
||||
logo_blink: BlinkStyles::default(),
|
||||
keep_awake: keep_awake::Settings::default(),
|
||||
window: None,
|
||||
}
|
||||
}
|
||||
@@ -426,12 +415,9 @@ mod tests {
|
||||
use crate::logo::BlinkStyle;
|
||||
let mut c = Config::default();
|
||||
assert_eq!(c.logo_blink, BlinkStyles::default());
|
||||
assert!(!c.logo_blink.enabled, "off until the user turns it on");
|
||||
c.logo_blink.enabled = true;
|
||||
c.logo_blink.needs_you = BlinkStyle::Wink;
|
||||
c.logo_blink.needs_you = BlinkStyle::Alarm;
|
||||
let text = serde_json::to_string(&c).unwrap();
|
||||
assert!(text.contains(r#""needsYou":"wink""#), "{text}");
|
||||
assert!(text.contains(r#""enabled":true"#), "{text}");
|
||||
assert!(text.contains(r#""needsYou":"alarm""#), "{text}");
|
||||
assert_eq!(serde_json::from_str::<Config>(&text).unwrap().logo_blink, c.logo_blink);
|
||||
for bad in [
|
||||
r#"{"logoBlink":3}"#,
|
||||
@@ -443,39 +429,11 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keep_awake_round_trips_and_a_bad_value_never_costs_the_file() {
|
||||
use crate::keep_awake::{Mode, Settings};
|
||||
let mut c = Config::default();
|
||||
assert_eq!(c.keep_awake, Settings::default());
|
||||
c.keep_awake.mode = Mode::WhileWorking;
|
||||
c.keep_awake.lid = true;
|
||||
let text = serde_json::to_string(&c).unwrap();
|
||||
assert!(
|
||||
text.contains(r#""keepAwake":{"mode":"whileWorking","acOnly":true,"lid":true}"#),
|
||||
"{text}"
|
||||
);
|
||||
assert_eq!(serde_json::from_str::<Config>(&text).unwrap().keep_awake, c.keep_awake);
|
||||
for bad in [
|
||||
r#"{"keepAwake":3}"#,
|
||||
r#"{"keepAwake":null}"#,
|
||||
r#"{"keepAwake":"always"}"#,
|
||||
] {
|
||||
let c: Config = serde_json::from_str(bad).unwrap();
|
||||
assert_eq!(c.keep_awake, Settings::default(), "{bad}");
|
||||
}
|
||||
// A file from before the setting: every other field still reads.
|
||||
let c: Config = serde_json::from_str(r#"{"startFullscreen":true}"#).unwrap();
|
||||
assert!(c.start_fullscreen);
|
||||
assert_eq!(c.keep_awake, Settings::default());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_style_a_mac_saved_before_there_were_states_is_kept() {
|
||||
use crate::logo::BlinkStyle;
|
||||
let c: Config = serde_json::from_str(r#"{"logoBlink":"heartbeat"}"#).unwrap();
|
||||
assert_eq!(c.logo_blink.working, BlinkStyle::Heartbeat);
|
||||
assert!(c.logo_blink.enabled);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
+371
-2691
File diff suppressed because it is too large.
Load diff
@@ -222,13 +222,6 @@ pub fn content_size(window: &tao::window::Window) -> Option<(f64, f64)> {
|
||||
imp::content_size(window)
|
||||
}
|
||||
|
||||
/// An app-modal message with an OK button, for something the user asked the app
|
||||
/// itself to do that failed. Main thread only; does nothing elsewhere.
|
||||
#[cfg(target_os = "macos")]
|
||||
pub fn show_message(title: &str, detail: &str) {
|
||||
imp::show_message(title, detail);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
mod imp {
|
||||
use std::cell::{Cell, RefCell};
|
||||
@@ -463,17 +456,6 @@ mod imp {
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn show_message(title: &str, detail: &str) {
|
||||
let Some(mtm) = MainThreadMarker::new() else {
|
||||
return;
|
||||
};
|
||||
let alert = NSAlert::new(mtm);
|
||||
alert.setMessageText(&NSString::from_str(title));
|
||||
alert.setInformativeText(&NSString::from_str(detail));
|
||||
let _ = alert.addButtonWithTitle(&NSString::from_str("OK"));
|
||||
alert.runModal();
|
||||
}
|
||||
|
||||
pub(super) fn content_size(window: &tao::window::Window) -> Option<(f64, f64)> {
|
||||
// NSWindow is main-thread only.
|
||||
MainThreadMarker::new()?;
|
||||
|
||||
+24
-284
@@ -27,7 +27,6 @@ mod assets;
|
||||
mod config;
|
||||
mod credentials;
|
||||
mod gui_install;
|
||||
mod keep_awake;
|
||||
mod local_server;
|
||||
mod login;
|
||||
mod logo;
|
||||
@@ -111,7 +110,6 @@ enter it again (a single-user Codeman accepts the right password at once), or wa
|
||||
const KEYCHAIN_UNREADABLE: &str = "The saved password could not be read from the Keychain. Enter it again.";
|
||||
const PAGE_KEEPS_STOPPING: &str = "Codeman's page stopped twice in a row, so it was closed. Connect again.";
|
||||
const LOGO_SWITCHED_OFF: &str = "The Apple logo is switched off on this Mac, so it cannot blink.";
|
||||
const LOGO_DRIVEN_ELSEWHERE: &str = "An older LogoLight is driving the Apple logo, so Codeman leaves it alone. Update LogoLight to 1.3 or later (or quit it) to see your agents on it.";
|
||||
/// What a download's toast calls a file whose name is not known.
|
||||
const UNNAMED_FILE: &str = "the file";
|
||||
const START_REFUSED: &str = "Codeman can only be started here for an http://127.0.0.1 or http://localhost address.";
|
||||
@@ -184,20 +182,6 @@ enum UserEvent {
|
||||
/// install opened in Terminal, the install command copied): the message
|
||||
/// goes to the page if it is still the one choosing a server.
|
||||
ToConnectPage(serde_json::Value),
|
||||
/// The keep-awake worker's status line, for the value numbered `generation`
|
||||
/// (keep_awake::KeepAwake::is_current).
|
||||
KeepAwakeStatus {
|
||||
generation: u64,
|
||||
text: String,
|
||||
},
|
||||
/// The administrator prompt for the lid helper was answered, and its command
|
||||
/// ran (keep_awake::install_helper, keep_awake::remove_helper).
|
||||
LidHelperDone {
|
||||
install: bool,
|
||||
outcome: keep_awake::AdminOutcome,
|
||||
},
|
||||
/// What the closed lid did with the logo last (logo::watch_lid_status).
|
||||
LogoLidStatus(String),
|
||||
}
|
||||
|
||||
struct ProbeDone {
|
||||
@@ -334,10 +318,6 @@ struct App {
|
||||
fullscreen_retry: Option<(Instant, u8)>,
|
||||
/// The child that watches the connected server and blinks the logo.
|
||||
logo_watch: logo::Watcher,
|
||||
/// The worker that keeps this Mac awake while its agents work.
|
||||
keep_awake: keep_awake::KeepAwake,
|
||||
/// An administrator prompt for the lid helper is open.
|
||||
lid_helper_busy: bool,
|
||||
}
|
||||
|
||||
fn main() {
|
||||
@@ -390,7 +370,6 @@ fn main() {
|
||||
let mut windows = HashMap::new();
|
||||
windows.insert(main_id, AppWindow::new(webview, window, Kind::Main));
|
||||
|
||||
let keep_awake_proxy = proxy.clone();
|
||||
let mut app = App {
|
||||
proxy,
|
||||
config,
|
||||
@@ -418,19 +397,8 @@ fn main() {
|
||||
add_agent_on_load: false,
|
||||
fullscreen_retry: wants_fullscreen.then(|| (Instant::now() + FULLSCREEN_FIRST_CHECK, 0)),
|
||||
logo_watch: logo::Watcher::default(),
|
||||
keep_awake: keep_awake::KeepAwake::new(move |generation, text| {
|
||||
let _ = keep_awake_proxy.send_event(UserEvent::KeepAwakeStatus { generation, text });
|
||||
}),
|
||||
lid_helper_busy: false,
|
||||
};
|
||||
|
||||
if app.menu.logo_lid.is_some() {
|
||||
let lid_proxy = app.proxy.clone();
|
||||
logo::watch_lid_status(move |line| {
|
||||
let _ = lid_proxy.send_event(UserEvent::LogoLidStatus(line));
|
||||
});
|
||||
}
|
||||
|
||||
event_loop.run(move |event, target, control_flow| app.handle(event, target, control_flow));
|
||||
}
|
||||
|
||||
@@ -735,18 +703,6 @@ fn build_webview(
|
||||
Ok(webview)
|
||||
}
|
||||
|
||||
/// Hides the whole app, as Cmd+H does. A click on the Dock icon (or Cmd+Tab)
|
||||
/// shows it again with every window as it was, a full-screen one included,
|
||||
/// which hiding the main window alone would not do.
|
||||
#[cfg(target_os = "macos")]
|
||||
fn hide_app(target: &EventLoopWindowTarget<UserEvent>) {
|
||||
use tao::platform::macos::EventLoopWindowTargetExtMacOS;
|
||||
target.hide_application();
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
fn hide_app(_target: &EventLoopWindowTarget<UserEvent>) {}
|
||||
|
||||
impl App {
|
||||
fn handle(
|
||||
&mut self,
|
||||
@@ -763,20 +719,17 @@ impl App {
|
||||
self.menu.help_menu.set_as_help_menu_for_nsapp();
|
||||
}
|
||||
}
|
||||
Event::WindowEvent { window_id, event, .. } => self.on_window_event(window_id, event, target, control_flow),
|
||||
Event::WindowEvent { window_id, event, .. } => self.on_window_event(window_id, event, control_flow),
|
||||
Event::UserEvent(event) => self.on_user_event(event, target),
|
||||
// A click on the Dock icon while no window is visible, which is
|
||||
// the main window minimized: tao only reports it, so bring the
|
||||
// window back here. An app hidden by closing its window
|
||||
// (`hide_app`) needs nothing here: AppKit unhides it, with every
|
||||
// window as it was, before it reports the click.
|
||||
// window back here.
|
||||
Event::Reopen {
|
||||
has_visible_windows: false,
|
||||
..
|
||||
} => self.show_main_window(),
|
||||
Event::MainEventsCleared => self.on_events_cleared(control_flow),
|
||||
Event::LoopDestroyed => {
|
||||
self.keep_awake.shutdown();
|
||||
self.record_main_frame();
|
||||
self.save_config();
|
||||
}
|
||||
@@ -837,34 +790,17 @@ impl App {
|
||||
self.fullscreen_retry = Some((now + FULLSCREEN_RETRY_EVERY, attempts + 1));
|
||||
}
|
||||
|
||||
fn on_window_event(
|
||||
&mut self,
|
||||
id: WindowId,
|
||||
event: WindowEvent,
|
||||
target: &EventLoopWindowTarget<UserEvent>,
|
||||
control_flow: &mut ControlFlow,
|
||||
) {
|
||||
fn on_window_event(&mut self, id: WindowId, event: WindowEvent, control_flow: &mut ControlFlow) {
|
||||
match event {
|
||||
WindowEvent::CloseRequested if id != self.main_id => self.remove_window(id),
|
||||
WindowEvent::CloseRequested => {
|
||||
self.record_main_frame();
|
||||
self.save_config();
|
||||
let server = self.phase.borrow().loaded().cloned();
|
||||
let mode = self.config.keep_awake.mode;
|
||||
if keep_awake::available()
|
||||
&& keep_awake::close_hides_app(mode, server.as_ref(), keep_awake::local_addresses)
|
||||
{
|
||||
// Keep This Mac Awake ends with the app, so closing the
|
||||
// window while it watches a Codeman on this Mac hides
|
||||
// Codeman instead, as Cmd+H does (full screen and pop-outs
|
||||
// included): the Mac stays awake, a click on the Dock icon
|
||||
// brings it back, and Cmd+Q still quits.
|
||||
hide_app(target);
|
||||
} else {
|
||||
if id == self.main_id {
|
||||
// Closing the main window quits; the server and its
|
||||
// sessions keep running, so nothing is lost.
|
||||
self.keep_awake.shutdown();
|
||||
self.record_main_frame();
|
||||
self.save_config();
|
||||
*control_flow = ControlFlow::Exit;
|
||||
} else {
|
||||
self.remove_window(id);
|
||||
}
|
||||
}
|
||||
WindowEvent::Moved(_) | WindowEvent::Resized(_) if id == self.main_id => self.record_main_frame(),
|
||||
@@ -885,7 +821,6 @@ impl App {
|
||||
self.to_connect_page(msg);
|
||||
}
|
||||
}
|
||||
UserEvent::LogoLidStatus(line) => self.show_lid_status(line),
|
||||
UserEvent::ServerStartDone {
|
||||
generation,
|
||||
url,
|
||||
@@ -940,7 +875,6 @@ impl App {
|
||||
*self.phase.borrow_mut() = next;
|
||||
}
|
||||
self.sync_logo_watch();
|
||||
self.sync_keep_awake();
|
||||
}
|
||||
if let Some(w) = self.windows.get_mut(&window) {
|
||||
w.committed = Url::parse(&url).ok();
|
||||
@@ -1003,14 +937,6 @@ impl App {
|
||||
}
|
||||
UserEvent::AuthRejected => self.recheck_login(),
|
||||
UserEvent::ContentProcessGone { window } => self.on_content_process_gone(window),
|
||||
UserEvent::KeepAwakeStatus { generation, text } => {
|
||||
if self.keep_awake.is_current(generation) {
|
||||
if let Some(items) = &self.menu.keep_awake {
|
||||
items.status.set_text(&text);
|
||||
}
|
||||
}
|
||||
}
|
||||
UserEvent::LidHelperDone { install, outcome } => self.on_lid_helper_done(install, outcome),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1104,214 +1030,35 @@ impl App {
|
||||
}
|
||||
}
|
||||
|
||||
/// The logo is watched while the switch is on, a state has a style and a
|
||||
/// server is open.
|
||||
/// The logo is watched while any style is chosen and a server is open.
|
||||
fn sync_logo_watch(&mut self) {
|
||||
let styles = self.config.logo_blink;
|
||||
let phase = self.phase.borrow();
|
||||
let server = phase.loaded().filter(|_| styles.active() && logo::available());
|
||||
let server = phase.loaded().filter(|_| styles.any() && logo::available());
|
||||
self.logo_watch
|
||||
.sync(server.map(|url| (url.as_str(), self.auth.as_deref(), styles)));
|
||||
drop(phase);
|
||||
self.show_lid_status(logo::lid_status_line());
|
||||
}
|
||||
|
||||
/// The line under the logo's styles: what the closed lid did last, or that
|
||||
/// nothing watches it now.
|
||||
fn show_lid_status(&self, line: String) {
|
||||
if let Some(item) = &self.menu.logo_lid {
|
||||
if self.logo_watch.watching() {
|
||||
item.set_text(line);
|
||||
} else {
|
||||
item.set_text(logo::LID_NOT_WATCHED);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn choose_logo_blink(&mut self, signal: Signal, style: BlinkStyle) {
|
||||
let was_active = self.config.logo_blink.active();
|
||||
// A click flips the item it landed on, the chosen one included, so all
|
||||
// of that state's are set.
|
||||
for (item_signal, item_style, item) in &self.menu.logo_styles {
|
||||
if *item_signal == signal {
|
||||
item.set_checked(*item_style == style);
|
||||
}
|
||||
}
|
||||
let was_off = !self.config.logo_blink.any();
|
||||
self.config.logo_blink.set(signal, style);
|
||||
// Picking a style is asking for the logo to show it.
|
||||
if style != BlinkStyle::Off {
|
||||
self.config.logo_blink.enabled = true;
|
||||
}
|
||||
self.logo_blink_changed(was_active);
|
||||
}
|
||||
|
||||
/// The one switch. Turned on with every state off, it brings the recommended
|
||||
/// styles, so that on always shows something.
|
||||
fn toggle_logo_blink(&mut self) {
|
||||
let was_active = self.config.logo_blink.active();
|
||||
let on = !self.config.logo_blink.enabled;
|
||||
self.config.logo_blink.enabled = on;
|
||||
if on && !self.config.logo_blink.any_style() {
|
||||
self.config.logo_blink = logo::BlinkStyles::RECOMMENDED;
|
||||
}
|
||||
self.logo_blink_changed(was_active);
|
||||
}
|
||||
|
||||
fn logo_blink_changed(&mut self, was_active: bool) {
|
||||
// A click flips the item it landed on, so every item is set from the
|
||||
// settings, the clicked one included.
|
||||
let styles = self.config.logo_blink;
|
||||
if let Some(switch) = &self.menu.logo_switch {
|
||||
switch.set_checked(styles.enabled);
|
||||
}
|
||||
for (signal, style, item) in &self.menu.logo_styles {
|
||||
item.set_checked(styles.get(*signal) == *style);
|
||||
}
|
||||
self.config_dirty = true;
|
||||
self.sync_logo_watch();
|
||||
// Only from off: with a blink running, a dark logo may be our own.
|
||||
if was_active || !styles.active() {
|
||||
return;
|
||||
}
|
||||
let warning = if logo::switched_off() {
|
||||
LOGO_SWITCHED_OFF
|
||||
} else if logo::driven_elsewhere() {
|
||||
LOGO_DRIVEN_ELSEWHERE
|
||||
} else {
|
||||
return;
|
||||
};
|
||||
if let Some(main) = self.windows.get(&self.main_id) {
|
||||
let _ = main.webview.evaluate_script(&assets::toast_script(warning, "warning"));
|
||||
}
|
||||
}
|
||||
|
||||
/// Keep-awake watches the open server while a mode is chosen. Whether that
|
||||
/// server is this Mac is the worker's to find out.
|
||||
fn sync_keep_awake(&mut self) {
|
||||
let settings = self.config.keep_awake;
|
||||
let server = self.phase.borrow().loaded().cloned();
|
||||
let watch = server
|
||||
.filter(|_| settings.mode != keep_awake::Mode::Off)
|
||||
.map(|server| keep_awake::Watch {
|
||||
server,
|
||||
authorization: self.auth.clone(),
|
||||
settings,
|
||||
});
|
||||
let idle = match (settings.mode, &watch) {
|
||||
(keep_awake::Mode::Off, _) => Some(keep_awake::OFF_TEXT),
|
||||
(_, None) => Some(keep_awake::NO_SERVER_TEXT),
|
||||
_ => None,
|
||||
};
|
||||
self.keep_awake.sync(watch);
|
||||
if let (Some(text), Some(items)) = (idle, &self.menu.keep_awake) {
|
||||
items.status.set_text(text);
|
||||
}
|
||||
}
|
||||
|
||||
fn choose_keep_awake_mode(&mut self, mode: keep_awake::Mode) {
|
||||
if let Some(items) = &self.menu.keep_awake {
|
||||
// A click flips the item it landed on, the chosen one included.
|
||||
for (item_mode, item) in &items.modes {
|
||||
item.set_checked(*item_mode == mode);
|
||||
// Only from off: with a style running, a dark logo may be our own.
|
||||
if was_off && style != BlinkStyle::Off && logo::switched_off() {
|
||||
if let Some(main) = self.windows.get(&self.main_id) {
|
||||
let _ = main
|
||||
.webview
|
||||
.evaluate_script(&assets::toast_script(LOGO_SWITCHED_OFF, "warning"));
|
||||
}
|
||||
}
|
||||
self.config.keep_awake.mode = mode;
|
||||
self.config_dirty = true;
|
||||
self.sync_keep_awake();
|
||||
}
|
||||
|
||||
fn toggle_keep_awake_ac_only(&mut self) {
|
||||
let Some(item) = self.menu.keep_awake.as_ref().and_then(|items| items.ac_only.as_ref()) else {
|
||||
return;
|
||||
};
|
||||
self.config.keep_awake.ac_only = item.is_checked();
|
||||
self.config_dirty = true;
|
||||
self.sync_keep_awake();
|
||||
}
|
||||
|
||||
/// The lid item is the helper: ticking it installs the helper behind the
|
||||
/// administrator prompt (unless this version's is already in), unticking it removes
|
||||
/// it behind another. The setting changes only once the prompt's command ran, so a
|
||||
/// cancelled or failed prompt leaves the item as it was.
|
||||
fn toggle_keep_awake_lid(&mut self) {
|
||||
use keep_awake::HelperState;
|
||||
let Some(wanted) = self
|
||||
.menu
|
||||
.keep_awake
|
||||
.as_ref()
|
||||
.and_then(|items| items.lid.as_ref())
|
||||
.map(|item| item.is_checked())
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let state =
|
||||
keep_awake::request_path().map_or(HelperState::Missing, |request| keep_awake::helper_state(&request));
|
||||
let prompt = match (wanted, state) {
|
||||
_ if self.lid_helper_busy => None,
|
||||
(true, HelperState::Installed) | (false, HelperState::Missing) => {
|
||||
self.config.keep_awake.lid = wanted;
|
||||
self.config_dirty = true;
|
||||
None
|
||||
}
|
||||
(install, _) => Some(install),
|
||||
};
|
||||
// The click flipped the item already. Until a prompt's command ran it shows
|
||||
// what is true.
|
||||
if let Some(items) = &self.menu.keep_awake {
|
||||
items.show_helper(state, self.config.keep_awake.lid);
|
||||
}
|
||||
match prompt {
|
||||
Some(install) => self.run_lid_helper(install),
|
||||
None => self.sync_keep_awake(),
|
||||
}
|
||||
}
|
||||
|
||||
fn run_lid_helper(&mut self, install: bool) {
|
||||
if self.lid_helper_busy {
|
||||
return;
|
||||
}
|
||||
self.lid_helper_busy = true;
|
||||
let proxy = self.proxy.clone();
|
||||
std::thread::spawn(move || {
|
||||
let outcome = match install {
|
||||
true => keep_awake::install_helper(),
|
||||
false => keep_awake::remove_helper(),
|
||||
};
|
||||
let _ = proxy.send_event(UserEvent::LidHelperDone { install, outcome });
|
||||
});
|
||||
}
|
||||
|
||||
fn on_lid_helper_done(&mut self, install: bool, outcome: keep_awake::AdminOutcome) {
|
||||
use keep_awake::{AdminOutcome, HelperState};
|
||||
self.lid_helper_busy = false;
|
||||
let state =
|
||||
keep_awake::request_path().map_or(HelperState::Missing, |request| keep_awake::helper_state(&request));
|
||||
let failure = match (&outcome, install, state) {
|
||||
(AdminOutcome::Cancelled, _, _) => None,
|
||||
(AdminOutcome::Failed(error), true, _) => Some(("The lid helper could not be installed.", error.clone())),
|
||||
(AdminOutcome::Failed(error), false, _) => Some(("The lid helper could not be removed.", error.clone())),
|
||||
(AdminOutcome::Done, true, HelperState::Installed) => {
|
||||
self.config.keep_awake.lid = true;
|
||||
self.config_dirty = true;
|
||||
None
|
||||
}
|
||||
(AdminOutcome::Done, true, _) => Some((
|
||||
"The lid helper could not be installed.",
|
||||
"The installation ran, but the helper's files are not what the app wrote.".to_owned(),
|
||||
)),
|
||||
(AdminOutcome::Done, false, HelperState::Missing) => {
|
||||
self.config.keep_awake.lid = false;
|
||||
self.config_dirty = true;
|
||||
None
|
||||
}
|
||||
(AdminOutcome::Done, false, _) => Some((
|
||||
"The lid helper could not be removed.",
|
||||
"The removal ran, but the helper's files are still there.".to_owned(),
|
||||
)),
|
||||
};
|
||||
if let Some(items) = &self.menu.keep_awake {
|
||||
items.show_helper(state, self.config.keep_awake.lid);
|
||||
}
|
||||
self.keep_awake.refresh();
|
||||
self.sync_keep_awake();
|
||||
if let Some((title, detail)) = failure {
|
||||
eprintln!("codeman-desktop: {title} {detail}");
|
||||
#[cfg(target_os = "macos")]
|
||||
macos::show_message(title, &detail);
|
||||
}
|
||||
}
|
||||
|
||||
fn on_menu(&mut self, id: &str) {
|
||||
@@ -1319,17 +1066,10 @@ impl App {
|
||||
self.choose_logo_blink(signal, style);
|
||||
return;
|
||||
}
|
||||
if let Some(mode) = keep_awake::Mode::from_menu_id(id) {
|
||||
self.choose_keep_awake_mode(mode);
|
||||
return;
|
||||
}
|
||||
match id {
|
||||
keep_awake::AC_ONLY_ID => self.toggle_keep_awake_ac_only(),
|
||||
keep_awake::LID_ID => self.toggle_keep_awake_lid(),
|
||||
menu_id::SWITCH => self.show_connect_page(None, true),
|
||||
menu_id::ADD_AGENT => self.add_agent(),
|
||||
menu_id::RELOAD => self.reload_focused(),
|
||||
menu_id::LOGO_BLINK => self.toggle_logo_blink(),
|
||||
menu_id::START_FULLSCREEN => {
|
||||
let value = self.menu.start_fullscreen.is_checked();
|
||||
self.config.start_fullscreen = value;
|
||||
|
||||
@@ -4,7 +4,6 @@ use muda::accelerator::{Accelerator, Code, Modifiers};
|
||||
use muda::{AboutMetadata, CheckMenuItem, Menu, MenuItem, PredefinedMenuItem, Submenu};
|
||||
|
||||
use crate::config::Config;
|
||||
use crate::keep_awake::{self, HelperState, Mode};
|
||||
use crate::logo::{self, BlinkStyle, Signal};
|
||||
use crate::{APP_NAME, VERSION};
|
||||
|
||||
@@ -17,7 +16,6 @@ pub mod menu_id {
|
||||
pub const ADD_AGENT: &str = "add-agent";
|
||||
pub const RELOAD: &str = "reload";
|
||||
pub const START_FULLSCREEN: &str = "start-fullscreen";
|
||||
pub const LOGO_BLINK: &str = "logo-blink";
|
||||
pub const DEVTOOLS: &str = "devtools";
|
||||
pub const HELP_DOCS: &str = "help-docs";
|
||||
pub const HELP_GITHUB: &str = "help-github";
|
||||
@@ -28,85 +26,9 @@ pub struct AppMenu {
|
||||
pub window_menu: Submenu,
|
||||
pub help_menu: Submenu,
|
||||
pub start_fullscreen: CheckMenuItem,
|
||||
/// The one switch for the logo. None on a Mac without a lit logo, which has no
|
||||
/// One item per state and style. Empty on a Mac without a lit logo, which has no
|
||||
/// such menu.
|
||||
pub logo_switch: Option<CheckMenuItem>,
|
||||
/// One item per state and style, empty without the switch.
|
||||
pub logo_styles: Vec<(Signal, BlinkStyle, CheckMenuItem)>,
|
||||
/// What the closed lid did with the logo last, a line that cannot be
|
||||
/// clicked. None without the switch.
|
||||
pub logo_lid: Option<MenuItem>,
|
||||
/// None where the app cannot keep the Mac awake (a build for another OS).
|
||||
pub keep_awake: Option<KeepAwakeMenu>,
|
||||
}
|
||||
|
||||
/// App menu > Keep This Mac Awake: Off / While Agents Work / Always, then on a
|
||||
/// MacBook Only When Plugged In and With the Lid Closed…, then one status line.
|
||||
pub struct KeepAwakeMenu {
|
||||
pub modes: Vec<(Mode, CheckMenuItem)>,
|
||||
/// Only on a Mac with a lid: a desktop Mac always runs on mains and cannot close.
|
||||
pub ac_only: Option<CheckMenuItem>,
|
||||
/// Ticked = the helper is in and asked for. Ticking or unticking it asks for the
|
||||
/// administrator password (install, remove), hence the ellipsis.
|
||||
pub lid: Option<CheckMenuItem>,
|
||||
/// What it does right now (keep_awake::status_line). Not clickable.
|
||||
pub status: MenuItem,
|
||||
}
|
||||
|
||||
impl KeepAwakeMenu {
|
||||
/// The lid item as the helper now is: ticked only while it is installed and on.
|
||||
pub fn show_helper(&self, state: HelperState, lid_on: bool) {
|
||||
if let Some(item) = &self.lid {
|
||||
item.set_checked(lid_on && state == HelperState::Installed);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn build_keep_awake(config: &Config) -> muda::Result<(Submenu, KeepAwakeMenu)> {
|
||||
let settings = config.keep_awake;
|
||||
let submenu = Submenu::new("Keep This Mac Awake", true);
|
||||
let mut modes = Vec::new();
|
||||
for mode in Mode::ALL {
|
||||
let item = CheckMenuItem::with_id(mode.menu_id(), mode.label(), true, settings.mode == mode, None);
|
||||
submenu.append(&item)?;
|
||||
modes.push((mode, item));
|
||||
}
|
||||
let (mut ac_only, mut lid) = (None, None);
|
||||
if keep_awake::has_lid() {
|
||||
let state =
|
||||
keep_awake::request_path().map_or(HelperState::Missing, |request| keep_awake::helper_state(&request));
|
||||
let ac = CheckMenuItem::with_id(
|
||||
keep_awake::AC_ONLY_ID,
|
||||
"Only When Plugged In",
|
||||
true,
|
||||
settings.ac_only,
|
||||
None,
|
||||
);
|
||||
let lid_item = CheckMenuItem::with_id(
|
||||
keep_awake::LID_ID,
|
||||
"With the Lid Closed\u{2026}",
|
||||
true,
|
||||
settings.lid && state == HelperState::Installed,
|
||||
None,
|
||||
);
|
||||
submenu.append_items(&[&PredefinedMenuItem::separator(), &ac, &lid_item])?;
|
||||
(ac_only, lid) = (Some(ac), Some(lid_item));
|
||||
}
|
||||
let initial = match settings.mode {
|
||||
Mode::Off => keep_awake::OFF_TEXT,
|
||||
_ => keep_awake::NO_SERVER_TEXT,
|
||||
};
|
||||
let status = MenuItem::new(initial, false, None);
|
||||
submenu.append_items(&[&PredefinedMenuItem::separator(), &status])?;
|
||||
Ok((
|
||||
submenu,
|
||||
KeepAwakeMenu {
|
||||
modes,
|
||||
ac_only,
|
||||
lid,
|
||||
status,
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
pub fn build_menu(config: &Config) -> muda::Result<AppMenu> {
|
||||
@@ -145,15 +67,6 @@ pub fn build_menu(config: &Config) -> muda::Result<AppMenu> {
|
||||
&PredefinedMenuItem::quit(None),
|
||||
],
|
||||
)?;
|
||||
// Right under "Add a Coding Agent to This Mac…".
|
||||
let keep_awake = match keep_awake::available() {
|
||||
true => {
|
||||
let (submenu, items) = build_keep_awake(config)?;
|
||||
app_menu.insert(&submenu, 4)?;
|
||||
Some(items)
|
||||
}
|
||||
false => None,
|
||||
};
|
||||
// Without these items Cmd+C / Cmd+V do nothing inside a WKWebView.
|
||||
let edit = Submenu::with_items(
|
||||
"Edit",
|
||||
@@ -197,20 +110,10 @@ pub fn build_menu(config: &Config) -> muda::Result<AppMenu> {
|
||||
),
|
||||
],
|
||||
)?;
|
||||
// Only a Mac with a lit Apple logo has these items at all: the switch, and the
|
||||
// style for each state behind it.
|
||||
// Only a Mac with a lit Apple logo has this menu at all.
|
||||
let mut logo_styles = Vec::new();
|
||||
let mut logo_switch = None;
|
||||
let mut logo_lid = None;
|
||||
if logo::available() {
|
||||
let switch = CheckMenuItem::with_id(
|
||||
menu_id::LOGO_BLINK,
|
||||
"Blink Apple Logo",
|
||||
true,
|
||||
config.logo_blink.enabled,
|
||||
None,
|
||||
);
|
||||
let submenu = Submenu::new("Apple Logo Blink Styles", true);
|
||||
let submenu = Submenu::new("Blink Apple Logo", true);
|
||||
for signal in Signal::ALL {
|
||||
let group = Submenu::new(signal.label(), true);
|
||||
for style in BlinkStyle::ALL {
|
||||
@@ -225,12 +128,7 @@ pub fn build_menu(config: &Config) -> muda::Result<AppMenu> {
|
||||
submenu.append(&group)?;
|
||||
}
|
||||
// Right under "Always Open in Full Screen", before the separator.
|
||||
let lid = MenuItem::new(logo::lid_status_line(), false, None);
|
||||
view.insert(&switch, 4)?;
|
||||
view.insert(&submenu, 5)?;
|
||||
view.insert(&lid, 6)?;
|
||||
logo_switch = Some(switch);
|
||||
logo_lid = Some(lid);
|
||||
view.insert(&submenu, 4)?;
|
||||
}
|
||||
let window_menu = Submenu::with_items(
|
||||
"Window",
|
||||
@@ -258,9 +156,6 @@ pub fn build_menu(config: &Config) -> muda::Result<AppMenu> {
|
||||
window_menu,
|
||||
help_menu,
|
||||
start_fullscreen,
|
||||
logo_switch,
|
||||
logo_styles,
|
||||
keep_awake,
|
||||
logo_lid,
|
||||
})
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "codeman",
|
||||
"description": "Drive Codeman, the self-hosted session manager for AI coding agents, from inside a Claude Code session: spawn worker sessions, prompt them, wait for them, read their answers, clean up. Acts inside a Codeman-managed session; outside one it only reads saved Codex conversations offline.",
|
||||
"version": "1.50.1",
|
||||
"version": "1.50.0",
|
||||
"author": {
|
||||
"name": "Ark0N",
|
||||
"url": "https://github.com/Ark0N"
|
||||
|
||||
@@ -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', 'lastPickedCase'],
|
||||
'settings.json': ['lastUsedCase'],
|
||||
'intents.json': ['recentPrompts', 'updatedAt'],
|
||||
'docker-cases.json': ['lastClaudeSessionId'],
|
||||
'webviews.json': ['lastOpenedAt'],
|
||||
|
||||
+702
-74
File diff suppressed because it is too large.
Load diff
@@ -808,6 +808,45 @@ export async function removePartialModelBlobs(paths?: LocalModelPaths): Promise<
|
||||
return freed;
|
||||
}
|
||||
|
||||
/** How many files of the blob folder are counted at most: a store holds a few dozen, and a walk must end. */
|
||||
const STORE_MEASURE_MAX_FILES = 20_000;
|
||||
|
||||
/**
|
||||
* What the model store takes on disk: the sizes of the regular files directly in
|
||||
* `<models>/blobs`, summed. That folder is flat (one file per layer, plus the partial files of
|
||||
* a download), so this is the store's real disk use, shared layers counted once, an
|
||||
* interrupted download included. The manifests beside it are a few kilobytes and are left out.
|
||||
*
|
||||
* One `readdir` and one `lstat` per file, at most `STORE_MEASURE_MAX_FILES` of them. Nothing is
|
||||
* followed: a `root`, a model store or a blob folder that is a symbolic link counts as empty,
|
||||
* and so does a link in the place of a file. Never outside `LocalModelPaths.root`. Resolves 0
|
||||
* when there is no store. Never throws.
|
||||
*/
|
||||
export async function measureModelStore(paths?: LocalModelPaths): Promise<number> {
|
||||
let total = 0;
|
||||
try {
|
||||
const resolved = paths ?? localModelPaths(OLLAMA_RUNTIME_PIN.version);
|
||||
const blobs = join(resolved.modelsDir, 'blobs');
|
||||
if (!isInsideLocalModelRoot(blobs, resolved.root)) return 0;
|
||||
for (const folder of [resolved.root, resolved.modelsDir, blobs]) {
|
||||
const info = await lstat(folder);
|
||||
if (info.isSymbolicLink() || !info.isDirectory()) return 0;
|
||||
}
|
||||
const names = await readdir(blobs);
|
||||
for (const name of names.slice(0, STORE_MEASURE_MAX_FILES)) {
|
||||
try {
|
||||
const info = await lstat(join(blobs, name));
|
||||
if (info.isFile()) total += info.size;
|
||||
} catch {
|
||||
// Gone between the listing and the look: a download or a delete is under way.
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// No model store yet.
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
// ---------- Port ----------
|
||||
|
||||
/** Whether nothing holds `port` on 127.0.0.1, decided by binding it and letting go again. */
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
type LocalModelErrorCode,
|
||||
type LocalModelInstallState,
|
||||
type LocalModelInstallStep,
|
||||
type LocalModelLibraryEntry,
|
||||
type LocalModelStateFile,
|
||||
} from '../types.js';
|
||||
import { OLLAMA_RUNTIME_PIN } from './runtime.js';
|
||||
@@ -153,6 +154,41 @@ function parseConnection(value: unknown): LocalModelStateFile['connection'] {
|
||||
: undefined;
|
||||
}
|
||||
|
||||
/** How many checked models a state file may list: far more than one Mac holds, and a bound on what a damaged file can cost. */
|
||||
const MAX_LIBRARY_ENTRIES = 64;
|
||||
|
||||
/**
|
||||
* The checked models of a file. An entry without its id, label, context or digest is dropped
|
||||
* (it could never be switched to), and so is a second entry for an id. `undefined` for a file
|
||||
* that has no list, which is what an earlier build wrote.
|
||||
*/
|
||||
function parseLibrary(value: unknown): LocalModelLibraryEntry[] | undefined {
|
||||
if (!Array.isArray(value)) return undefined;
|
||||
const entries: LocalModelLibraryEntry[] = [];
|
||||
for (const item of value) {
|
||||
if (entries.length >= MAX_LIBRARY_ENTRIES) break;
|
||||
if (!isRecord(item)) continue;
|
||||
const id = text(item.id, 200);
|
||||
const label = text(item.label, 200);
|
||||
const context = count(item.context);
|
||||
const digest = text(item.digest, 200);
|
||||
if (!id || !label || !context || !digest || entries.some((entry) => entry.id === id)) continue;
|
||||
const bytes = count(item.bytes);
|
||||
const cli = parseCli(item.cli);
|
||||
const verifiedAt = text(item.verifiedAt, 64);
|
||||
entries.push({
|
||||
id,
|
||||
label,
|
||||
context,
|
||||
digest,
|
||||
...(bytes ? { bytes } : {}),
|
||||
...(cli ? { cli } : {}),
|
||||
...(verifiedAt ? { verifiedAt } : {}),
|
||||
});
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
/**
|
||||
* The state a file's text stands for. Pure and total: text that is not a JSON object of
|
||||
* `schema: 1` with a known `state` is `IDLE_LOCAL_MODEL_STATE`. Members it does not know are
|
||||
@@ -180,6 +216,7 @@ export function parseLocalModelState(raw: string): LocalModelStateFile {
|
||||
const connection = parseConnection(parsed.connection);
|
||||
const updatedAt = text(parsed.updatedAt, 64);
|
||||
const replacedModelId = text(parsed.replacedModelId, 200);
|
||||
const library = parseLibrary(parsed.library);
|
||||
if (!model || !cli) return IDLE_LOCAL_MODEL_STATE;
|
||||
|
||||
if (state === 'installed') {
|
||||
@@ -192,6 +229,7 @@ export function parseLocalModelState(raw: string): LocalModelStateFile {
|
||||
cli,
|
||||
runtime,
|
||||
...(loaded ? { loaded } : {}),
|
||||
...(library ? { library } : {}),
|
||||
...(connection ? { connection } : {}),
|
||||
...(updatedAt ? { updatedAt } : {}),
|
||||
};
|
||||
@@ -215,6 +253,7 @@ export function parseLocalModelState(raw: string): LocalModelStateFile {
|
||||
...(runtime ? { runtime } : {}),
|
||||
...(progress ? { progress } : {}),
|
||||
...(loaded ? { loaded } : {}),
|
||||
...(library ? { library } : {}),
|
||||
...(connection ? { connection } : {}),
|
||||
...(replacedModelId ? { replacedModelId } : {}),
|
||||
...(updatedAt ? { updatedAt } : {}),
|
||||
|
||||
@@ -115,12 +115,6 @@ export interface FileWriteData {
|
||||
eol: 'lf' | 'crlf';
|
||||
}
|
||||
|
||||
/** Response payload for `DELETE /api/sessions/:id/files?path=` (the Files panel's delete button). */
|
||||
export interface FileDeleteData {
|
||||
/** The path as submitted (workspace-relative, or absolute inside the session's outside worktree) */
|
||||
path: string;
|
||||
}
|
||||
|
||||
export type CleanupResourceType = 'timer' | 'interval' | 'watcher' | 'listener' | 'stream';
|
||||
|
||||
/**
|
||||
|
||||
@@ -24,7 +24,11 @@
|
||||
* | `GET /api/local-model` | none | none (a failed probe is an answer) |
|
||||
* | `POST /api/local-model/install` | `LocalModelInstallRequest` | 400 not a row the server offers, or a saved endpoint |
|
||||
* | | | already has the reserved id; 409 `job-running`, |
|
||||
* | | | `already-installed`, `size-changed` |
|
||||
* | | | `already-installed` (this model at this context is |
|
||||
* | | | the one in use), `size-changed`, `sessions-open` |
|
||||
* | `POST /api/local-model/use` | `LocalModelUseRequest` | 400 not a checked model of the library, or no coding |
|
||||
* | | | agent for it is installed; 409 `job-running`, |
|
||||
* | | | `wrong-state`, `already-installed`, `sessions-open` |
|
||||
* | `POST /api/local-model/cancel` | none | 409 `wrong-state` (no job is running) |
|
||||
* | `POST /api/local-model/resume` | `LocalModelResumeRequest` | 409 `job-running`, `wrong-state` (not paused or |
|
||||
* | | | failed), `sessions-open` (another port while live |
|
||||
@@ -33,6 +37,9 @@
|
||||
* | | | `sessions-open` (stop, or another port, while live |
|
||||
* | | | sessions use the model) |
|
||||
* | `DELETE /api/local-model` | `LocalModelRemoveRequest` | 409 `job-running`, `sessions-open` |
|
||||
* | `DELETE /api/local-model/models` | `LocalModelDeleteRequest` | 400 not a model of this Mac; 409 `job-running`, |
|
||||
* | | | `wrong-state`, `model-active`, `runtime-not-running`; |
|
||||
* | | | 500 the runtime did not delete it |
|
||||
*
|
||||
* A 409 is the error envelope plus `data: LocalModelConflict`:
|
||||
* `{ success: false, error, errorCode: 'CONFLICT', data: { reason, message, ... } }`. A 409
|
||||
@@ -141,7 +148,10 @@ export const LOCAL_MODEL_INSTALL_STEPS: readonly LocalModelInstallStep[] = [
|
||||
* | `harness-failed` | the coding agent's install command failed, or its binary is still missing |
|
||||
* | `endpoint-id-taken` | a saved endpoint with our id exists that this feature did not write |
|
||||
* | `connect-failed` | the endpoint record or the settings flag could not be written |
|
||||
* | `model-changed` | installed: the model's digest is not the one stored at install time |
|
||||
* | `model-changed` | installed: the model's digest is not the one stored at install time. A |
|
||||
* | | switch: the model's digest is not the one it was checked with |
|
||||
* | `job-unfinished` | a job that changes the model in use is running or stopped. Only a |
|
||||
* | | refused session start says it: nothing is started until the job ended |
|
||||
* | `internal` | anything unexpected (the exception text is in `detail`) |
|
||||
*/
|
||||
export type LocalModelErrorCode =
|
||||
@@ -164,6 +174,7 @@ export type LocalModelErrorCode =
|
||||
| 'endpoint-id-taken'
|
||||
| 'connect-failed'
|
||||
| 'model-changed'
|
||||
| 'job-unfinished'
|
||||
| 'internal';
|
||||
|
||||
/**
|
||||
@@ -192,12 +203,25 @@ export type LocalModelNextStep = 'retry' | 'other-port' | 'next-model' | 'start'
|
||||
* - `cancel`: `POST /api/local-model/cancel`.
|
||||
* - `resume`: `POST /api/local-model/resume`.
|
||||
* - `start` and `stop`: `POST /api/local-model/runtime`.
|
||||
* - `remove`: `DELETE /api/local-model`.
|
||||
* - `remove`: `DELETE /api/local-model`. In `idle` it is listed only while the model store
|
||||
* still holds something (models an earlier Remove kept): it is then what frees that space.
|
||||
* - `start-session`: not a route of this feature: the window starts a session on the endpoint
|
||||
* `endpointId` with the coding agent `cli`. Offered only while the runtime runs and nothing
|
||||
* blocks a session.
|
||||
* - `use`: `POST /api/local-model/use`. Listed only while a row of `library` offers it.
|
||||
* - `delete-model`: `DELETE /api/local-model/models`. Listed only while a row of `library`
|
||||
* offers it.
|
||||
*/
|
||||
export type LocalModelAction = 'install' | 'cancel' | 'resume' | 'start' | 'stop' | 'remove' | 'start-session';
|
||||
export type LocalModelAction =
|
||||
| 'install'
|
||||
| 'cancel'
|
||||
| 'resume'
|
||||
| 'start'
|
||||
| 'stop'
|
||||
| 'remove'
|
||||
| 'start-session'
|
||||
| 'use'
|
||||
| 'delete-model';
|
||||
|
||||
/** Whether the Mac has the developer tools (git, Python) a coding agent calls. */
|
||||
export type LocalModelDeveloperTools =
|
||||
@@ -225,7 +249,9 @@ export type LocalModelDeveloperTools =
|
||||
* | `errHarnessFailed` | `cli`: the coding agent's label |
|
||||
* | `errEndpointIdTaken` | `id`: the endpoint id this feature uses |
|
||||
* | `refusedAgentNotInstallable` | `cli`: the coding agent's label |
|
||||
* | `refusedOtherModel` | `model`: the label of the model that is installed |
|
||||
* | `refusedOtherModel` | `model`: the label of the model that is in use |
|
||||
* | `refusedAgentMissing` | `cli`: the coding agent's label |
|
||||
* | `conflictModelActive`, `deleteFailed` | `model`: the model's label |
|
||||
* | `conflictSizeChanged` | `size`: the server's own total as decimal GB |
|
||||
* | `conflictSessionsOpen` | `sessions`: the session names, joined with ', ' |
|
||||
*
|
||||
@@ -259,16 +285,23 @@ export const LOCAL_MODEL_INSTALL_LABELS = {
|
||||
'A saved model connection named {id} already exists and was not created here. Rename or delete it in App Settings, then try again.',
|
||||
errConnectFailed: 'Codeman could not save the connection to the model.',
|
||||
errModelChanged: 'The model on this Mac changed since it was installed. Remove it and install it again.',
|
||||
errJobUnfinished:
|
||||
'The model on this Mac is being changed and is not ready. Finish or undo that in the local model window first.',
|
||||
errInternal: 'The install stopped on an unexpected error.',
|
||||
conflictJobRunning: 'An install is already running.',
|
||||
conflictAlreadyInstalled: 'A model is already installed on this Mac. Remove it first.',
|
||||
conflictAlreadyInstalled: 'This model is already in use on this Mac.',
|
||||
conflictModelActive: '{model} is the model in use. Switch to another model first, or remove the local model.',
|
||||
conflictSizeChanged: 'The download is {size} GB now. Check the sizes and confirm again.',
|
||||
conflictSessionsOpen: 'Sessions are still using this model: {sessions}. Close them first.',
|
||||
conflictWrongState: 'That is not possible in the current state.',
|
||||
refusedModelNotOffered: 'This model at this context is not offered for this Mac right now.',
|
||||
refusedAgentNotOffered: 'This coding agent is not offered for this model.',
|
||||
refusedAgentNotInstallable: '{cli} is not installed and cannot be installed from here.',
|
||||
refusedOtherModel: 'Only {model} is installed on this Mac. Remove it and install another model first.',
|
||||
refusedOtherModel: 'Only {model} is in use on this Mac. Switch models in the local model window first.',
|
||||
refusedNotInLibrary: 'This model is not one of the checked models on this Mac.',
|
||||
refusedNotOnThisMac: 'This model is not on this Mac.',
|
||||
deleteFailed: '{model} could not be deleted from this Mac.',
|
||||
refusedAgentMissing: '{cli} is not installed on this Mac. Install this model again to choose a coding agent.',
|
||||
endpointManaged:
|
||||
'This endpoint belongs to the model Codeman runs on this Mac. Change or remove it in the local model window.',
|
||||
endpointIdReserved: 'This endpoint id is reserved for the model Codeman runs on this Mac. Pick another id.',
|
||||
@@ -307,6 +340,7 @@ export const LOCAL_MODEL_ERROR_LABELS: Record<LocalModelErrorCode, LocalModelIns
|
||||
'endpoint-id-taken': LOCAL_MODEL_INSTALL_LABELS.errEndpointIdTaken,
|
||||
'connect-failed': LOCAL_MODEL_INSTALL_LABELS.errConnectFailed,
|
||||
'model-changed': LOCAL_MODEL_INSTALL_LABELS.errModelChanged,
|
||||
'job-unfinished': LOCAL_MODEL_INSTALL_LABELS.errJobUnfinished,
|
||||
internal: LOCAL_MODEL_INSTALL_LABELS.errInternal,
|
||||
};
|
||||
|
||||
@@ -331,6 +365,7 @@ export const LOCAL_MODEL_ERROR_NEXT: Record<LocalModelErrorCode, LocalModelNextS
|
||||
'endpoint-id-taken': 'retry',
|
||||
'connect-failed': 'retry',
|
||||
'model-changed': 'remove',
|
||||
'job-unfinished': 'retry',
|
||||
internal: 'retry',
|
||||
};
|
||||
|
||||
@@ -426,7 +461,8 @@ export interface LocalModelLoadedFacts {
|
||||
* | `port` | | once chosen | once chosen | once chosen | yes |
|
||||
* | `loaded`, `endpointId` | | | | | yes |
|
||||
*
|
||||
* `running`, `developerTools`, `runtime`, `folder` and `actions` are always present.
|
||||
* `running`, `developerTools`, `runtime`, `folder`, `library` and `actions` are always
|
||||
* present. `store` is present once the model store was measured.
|
||||
*/
|
||||
export interface LocalModelInstallView {
|
||||
state: LocalModelInstallState;
|
||||
@@ -454,10 +490,63 @@ export interface LocalModelInstallView {
|
||||
loaded?: LocalModelLoadedFacts;
|
||||
/** The id of the saved endpoint a session is started on. */
|
||||
endpointId?: string;
|
||||
/**
|
||||
* The models on this Mac: every model Codeman checked (`LocalModelStateFile.library`), plus
|
||||
* whatever else the running runtime lists. Empty in `idle` (no runtime is there to ask) and
|
||||
* before anything was downloaded. The model in use comes first.
|
||||
*/
|
||||
library: LocalModelLibraryRow[];
|
||||
/** What the model store takes on disk right now. Present in every state once it was measured. */
|
||||
store?: LocalModelStoreView;
|
||||
/** What the server accepts right now, see `LocalModelAction`. */
|
||||
actions: LocalModelAction[];
|
||||
}
|
||||
|
||||
/**
|
||||
* How a model of the library stands.
|
||||
* - `ready`: checked by Codeman, and the runtime (when it was asked) still lists it with the
|
||||
* digest that was stored.
|
||||
* - `changed`: checked, but the runtime now lists another digest under the name. It is never
|
||||
* started: `use` would fail with `model-changed`.
|
||||
* - `missing`: checked, but the runtime no longer lists it (something deleted it through the
|
||||
* runtime's port).
|
||||
* - `unverified`: the runtime lists it and Codeman never checked it: a model an earlier
|
||||
* Remove kept, the model of a job that has not passed its check yet, or one another program
|
||||
* pulled through the runtime's port. Never startable.
|
||||
*/
|
||||
export type LocalModelLibraryStatus = 'ready' | 'changed' | 'missing' | 'unverified';
|
||||
|
||||
/** What can be done with one row of `library`: `POST /api/local-model/use` and `DELETE /api/local-model/models`. */
|
||||
export type LocalModelLibraryAction = 'use' | 'delete';
|
||||
|
||||
/** One model on this Mac. */
|
||||
export interface LocalModelLibraryRow {
|
||||
/** The model's name exactly as the runtime lists it (`name:tag`). */
|
||||
id: string;
|
||||
/** Its catalogue label, or `id` for a model the catalogue does not know. */
|
||||
label: string;
|
||||
/** The context it was checked with. Absent for an `unverified` row. */
|
||||
context?: number;
|
||||
/** Its size on disk: what the runtime reports, else what was recorded when it was checked. */
|
||||
sizeBytes?: number;
|
||||
/** `sizeBytes` as decimal GB text, no unit. Present exactly when `sizeBytes` is. */
|
||||
sizeGb?: string;
|
||||
/** It is the model in use: the one of an `installed` state. */
|
||||
active: boolean;
|
||||
/** Codeman checked it (`LocalModelStateFile.library` holds it). */
|
||||
verified: boolean;
|
||||
status: LocalModelLibraryStatus;
|
||||
/** What the server accepts for this row right now. `delete` needs the running runtime. */
|
||||
actions: LocalModelLibraryAction[];
|
||||
}
|
||||
|
||||
/** The disk use of the model store: the files directly in its blob folder, summed. */
|
||||
export interface LocalModelStoreView {
|
||||
bytes: number;
|
||||
/** `bytes` as decimal GB text, no unit. */
|
||||
gb: string;
|
||||
}
|
||||
|
||||
/** The `data` of `GET /api/local-model`. Admin only in multi-user mode. */
|
||||
export interface LocalModelStatus {
|
||||
hardware: HardwareFacts;
|
||||
@@ -511,6 +600,18 @@ export interface LocalModelRemoveRequest {
|
||||
removeModel?: boolean;
|
||||
}
|
||||
|
||||
/** Body of `POST /api/local-model/use`: a checked model of `library` to switch to. Nothing is downloaded. */
|
||||
export interface LocalModelUseRequest {
|
||||
/** `LocalModelLibraryRow.id` of a row whose `actions` list `use`. */
|
||||
modelId: string;
|
||||
}
|
||||
|
||||
/** Body of `DELETE /api/local-model/models`: one model to delete from the store. */
|
||||
export interface LocalModelDeleteRequest {
|
||||
/** `LocalModelLibraryRow.id` of a row whose `actions` list `delete`. Never a path. */
|
||||
modelId: string;
|
||||
}
|
||||
|
||||
/** The `data` of a 400 the service names: its sentence, for the window to print in its own language. */
|
||||
export interface LocalModelRefusal {
|
||||
message: LocalModelInstallMessage;
|
||||
@@ -522,20 +623,26 @@ export type LocalModelVerbAnswer = LocalModelInstallView;
|
||||
/**
|
||||
* Why a verb was refused with 409.
|
||||
* - `job-running`: a job is running (a second install, a resume, a remove or a runtime action).
|
||||
* - `already-installed`: install while a model is installed. Remove it first.
|
||||
* - `already-installed`: install or use of the model that is already the one in use (at the
|
||||
* same context).
|
||||
* - `size-changed`: `acknowledgedBytes` is not the server's total; `expectedBytes` and
|
||||
* `expectedGb` carry that total, so the window can show it and ask again.
|
||||
* - `sessions-open`: remove, stop or a change of port while live sessions use the endpoint;
|
||||
* `sessions` names them. Their coding agents were given the runtime's address when they
|
||||
* started, so they would go on calling a port nothing of ours holds.
|
||||
* - `sessions-open`: remove, stop, a change of port, an install of another model or a switch
|
||||
* while live sessions use the endpoint; `sessions` names them. Their coding agents were
|
||||
* given the runtime's address and the model's name when they started, so they would go on
|
||||
* calling a port nothing of ours holds, or make the runtime swap models on every request.
|
||||
* - `wrong-state`: the verb makes no sense in the current state (see the route table above).
|
||||
* - `model-active`: a delete of the model the installation or the current job is about.
|
||||
* - `runtime-not-running`: a delete while the runtime is down: only the runtime deletes a model.
|
||||
*/
|
||||
export type LocalModelConflictReason =
|
||||
| 'job-running'
|
||||
| 'already-installed'
|
||||
| 'size-changed'
|
||||
| 'sessions-open'
|
||||
| 'wrong-state';
|
||||
| 'wrong-state'
|
||||
| 'model-active'
|
||||
| 'runtime-not-running';
|
||||
|
||||
/** The `data` of a 409 of an install verb. */
|
||||
export interface LocalModelConflict {
|
||||
@@ -622,6 +729,14 @@ export interface LocalModelStateFile {
|
||||
progress?: { completedBytes: number; totalBytes: number };
|
||||
/** What the runtime reported when the job checked the loaded model. */
|
||||
loaded?: { context: number; sizeBytes: number; sizeVramBytes: number };
|
||||
/**
|
||||
* Every model that PASSED the check through Codeman and is believed to be in the store, the
|
||||
* model in use among them. An entry is written when the `verify` step passes and dropped
|
||||
* when the model is deleted; `remove` drops the whole list. An `installed` file an earlier
|
||||
* build wrote has none: its one model then counts as the library (`libraryOf()` in
|
||||
* src/local-model/install-job.ts).
|
||||
*/
|
||||
library?: LocalModelLibraryEntry[];
|
||||
/**
|
||||
* Present once the `connect` step CLAIMED the connection, which it does before its two
|
||||
* writes: what uninstall must undo. A claim with nothing behind it (the step was interrupted
|
||||
@@ -636,3 +751,19 @@ export interface LocalModelStateFile {
|
||||
/** ISO time of the last write. */
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
/** One checked model of `LocalModelStateFile.library`. It holds no path. */
|
||||
export interface LocalModelLibraryEntry {
|
||||
id: string;
|
||||
label: string;
|
||||
/** The context length in tokens it was checked with. */
|
||||
context: number;
|
||||
/** The digest `/api/tags` reported when it passed the check. A switch to it must find the same one. */
|
||||
digest: string;
|
||||
/** Its size on disk as the runtime reported it then, for a listing while the runtime is stopped. */
|
||||
bytes?: number;
|
||||
/** The coding agent of the job that checked it. A switch back takes it when it is installed. */
|
||||
cli?: { id: string; label: string };
|
||||
/** ISO time of the check. */
|
||||
verifiedAt?: string;
|
||||
}
|
||||
@@ -554,6 +554,12 @@ export interface Candidate {
|
||||
installBytes: number;
|
||||
/** `installBytes` as decimal GB text, no unit. */
|
||||
installGb: string;
|
||||
/**
|
||||
* Set by the route, never by `recommend()`: this model at this context is a checked model of
|
||||
* the library on this Mac (`LocalModelInstallView.library`). `active`: it is the model in
|
||||
* use. `ready`: `POST /api/local-model/use` switches to it, and nothing is downloaded.
|
||||
*/
|
||||
library?: 'active' | 'ready';
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+1
-1
@@ -48,7 +48,7 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L
|
||||
|
||||
**Opened outside Codeman** (`foreign-sessions.js`, both home screens): the tmux sessions a human started, listed from `GET /api/mux/foreign` (root CLAUDE.md, Adopted tmux sessions). ONE renderer, `renderForeignSessions(container)`, fills the welcome block and the phone overview's, and every row goes through `textContent`, since a session name and a command line come from someone else's process. ⚠️ It polls only while a home screen is up (`startForeignPolling`/`stopForeignPolling` from `showWelcome()`/`hideWelcome()`) and the tab is visible (`_foreignPollTick()` is the one gate, and `visibilitychange` catches up; the cache TTL sits under the poll interval, so one tab's every tick is a full socket sweep), and the poll is local only: the docker and ssh scan costs one connection per target, so it runs once on the toggle and again on Rescan, and its rows and notes are kept apart (`_foreignWideSessions`, `_foreignWideNotes`) or the next local poll would wipe them. ⚠️ A 403 is a non-admin in multi-user mode, an answer and not a failed poll: `_foreignRefused` stops the polling and shows no block. ⚠️ `closeSession()` re-reads the list after an adopted tab's DELETE, since the home screen's own load races it and the row would say `Go to tab` until the next poll. ⚠️ An adopted tab has no kill outcome to offer, so the close dialog hides the red button and always tears the wrapper down. ⚠️ `session.remote` and `session.docker` are undefined on an adopted session, so a check that hides a row for those two usually needs `session.adopt` as well. Tests: `test/foreign-sessions-ui.test.ts`, `test/foreign-sessions-i18n.test.ts` (harvests every string from the real module).
|
||||
|
||||
**Run a model on this Mac** (`local-model-ui.js`; welcome button `#welcomeLocalModelBtn`, chooser window `#localModelModal`, App Settings > Models row `#localModelSettings`; server rules in the root CLAUDE.md, Local model): ONE window does everything (choose, confirm, progress, paused, failed, installed, no recommendation); the button and the Settings row only open it. Which screen shows follows `install.state` of `GET /api/local-model`; the page itself holds only the selection and whether the person is on the chooser or the confirm screen. ⚠️ The window is ONE frame for the whole flow: a fixed height on desktop (`.modal-content.local-model-window`), App Settings' boxed close button, the body scrolling between the header and a foot of its own (`#localModelWindowFoot`): every screen's last part (`.lm-foot`, or the `.lm-remove` question) is moved there by `_renderLocalModelWindow()`, so a button never moves away from under the pointer and is never clipped; a test that looks for a button looks in the frame, not the body. The short screens are centred in that frame on desktop and sit above the buttons on a phone (`data-screen` on the body). ⚠️ The memory bar (`.lm-bar`, `role="meter"`) is the one strong element: filled to the selected row's `budgetShare`, the model at its PEAK (the legend says so, and the installed line uses the same words beside what the model takes now, so the two figures are never read as a contradiction), the memory map (`Candidate.memory`: the model, its context and the brief peaks side by side, each named in the legend, on a track of `--text-muted` at about a third) in the feature's own colours (`--lm-*`: white for the feature, turned over to dark ink on the light skins, blue the context, green good, amber tight, orange slow, rose the brief peaks, deeper on the light skins), never `--accent`, which the owner rejected as too much blue (about 3:1 against the window, so the free part can be seen; a border token is 1.5:1 and the inputs' `--bg-input` is the window's own surface on the dark skins), 10px, no gradient or glow, and its width transition is the only motion (off under `prefers-reduced-motion`); a repaint starts each bar at the last width of its kind, so keep `_settleLocalModelBars()` after every body write. The figure beside it is an estimate and says "about" in whole GB (`localModelWholeGb()` on the server's text); only the download figure keeps tabular figures. ⚠️ The rows are the server's pick first, then the other models by download size, largest first (`localModelCandidates()`): the server's alternatives are its ranking, which no column shows. ⚠️ The download bar means ONE thing, the model download: at the start while the runtime is fetched, gone once the model is in (the step list carries verify, agent and connect); the first paint after an install finished shows "Start a session" alone (`justInstalled`), an error on an installed model is the headline with a grey dot, and Remove is quiet everywhere but red in its own question, never the accent. ⚠️ The window polls the GET once a second WHILE OPEN and never otherwise (no SSE event): `closeLocalModelWindow()` clears the timer, a background tab asks nothing, an unchanged answer repaints nothing, and a tick that moved only the download replaces `.lm-meter` alone, so a button is never swapped out under a click. A read that was already under way when a verb answered is dropped (`_localModelVerbs`), or the old state flashes back. ⚠️ The consent is bound to the size shown: the install request carries the row's `installBytes` as `acknowledgedBytes`, and a 409 `size-changed` shows `expectedGb` and sends `expectedBytes` next; never compute a total here. ⚠️ A button exists only for an action in `install.actions`, and after an error exactly one helpful action (`error.next`, already adapted by the server to the state: `start` on an installed model, `retry` on a job) plus a quiet Remove; an error's text is split after its first sentence (`localModelSentences()`, which knows the ideographic full stop) and only that sentence is the headline. A refusal the server names (a 400, a full disk) carries `data.message` like a 409 and is printed from it, never from the English `error` text. ⚠️ Every dynamic string goes in through `textContent` (model, coding agent and chip names, an error's detail, session names, the folder and the install command are someone else's text); the window body, the button and the row carry `data-i18n-skip` and the module translates its own sentences at render time, so a language or display-name change repaints from the module (the `<html lang>` observer). ⚠️ Labels and sentences come from the payload (`{ text, vars }` through `localModelT`); the module holds no list of models or coding agents, compares no CLI id (the agent select's values are positions in the server's list, the id travels only as data into the install request and `selectCustomModelEntry`) and never contains the word "RecoLine truncated
|
||||
**Run a model on this Mac** (`local-model-ui.js`; welcome button `#welcomeLocalModelBtn`, chooser window `#localModelModal`, App Settings > Models row `#localModelSettings`; server rules in the root CLAUDE.md, Local model): ONE window does everything (choose, confirm, progress, paused, failed, installed, no recommendation); the button and the Settings row only open it. ⚠️ The models on this Mac (`install.library`) are ONE list builder, `_localModelLibraryEl()`: on the installed screen always (the model in use first, in the chooser's selected look, each row with the context it was checked with, a state tag and its size on disk), on a paused or failed screen only when a row offers something (the way back to an earlier model), never on the screen that ends an install. A row has "Use" and "Delete" only for what the server lists for THAT row (`row.actions`); Use is a switch with no confirm screen (nothing is downloaded), Delete asks once in place under its row (`.lm-lib-ask`, the one red button, the focus on Cancel), and the asked row keeps its buttons unseen (`.lm-lib-actions-away`) so the sizes do not jump sideways. Under the list: what all downloads take (`install.store`) and "Add a model", which opens the chooser from an installed model; there a row with `candidate.library` says "In use" or "On this Mac" and the main button becomes a disabled "In use" or "Use this model". With nothing installed and a store an earlier Remove kept, the chooser and the nothing-fits screen say what it takes and offer "Delete them" (`_localModelStoreEl()`). ⚠️ A screen that carries the list sets `data-long` on the body, which switches it from the centred short-screen layout (bottom-aligned on a phone) to top-aligned: a centred flex column that overflows cuts off its first lines. ⚠️ The row buttons live in `.lm-lib-actions`, not `.lm-buttons` (the screen's own buttons in the foot), and are a size smaller (`.lm-btn-row`). ⚠️ A job whose steps hold no `model` step is a switch (`localModelIsSwitch()`) and is named one ("Switching to", no download bar). Which screen shows follows `install.state` of `GET /api/local-model`; the page itself holds only the selection and whether the person is on the chooser or the confirm screen. ⚠️ The window is ONE frame for the whole flow: a fixed height on desktop (`.modal-content.local-model-window`), App Settings' boxed close button, the body scrolling between the header and a foot of its own (`#localModelWindowFoot`): every screen's last part (`.lm-foot`, or the `.lm-remove` question) is moved there by `_renderLocalModelWindow()`, so a button never moves away from under the pointer and is never clipped; a test that looks for a button looks in the frame, not the body. The short screens are centred in that frame on desktop and sit above the buttons on a phone (`data-screen` on the body). ⚠️ The memory bar (`.lm-bar`, `role="meter"`) is the one strong element: filled to the selected row's `budgetShare`, the model at its PEAK (the legend says so, and the installed line uses the same words beside what the model takes now, so the two figures are never read as a contradiction), the memory map (`Candidate.memory`: the model, its context and the brief peaks side by side, each named in the legend, on a track of `--text-muted` at about a third) in the feature's own colours (`--lm-*`: white for the feature, turned over to dark ink on the light skins, blue the context, green good, amber tight, orange slow, rose the brief peaks, deeper on the light skins), never `--accent`, which the owner rejected as too much blue (about 3:1 against the window, so the free part can be seen; a border token is 1.5:1 and the inputs' `--bg-input` is the window's own surface on the dark skins), 10px, no gradient or glow, and its width transition is the only motion (off under `prefers-reduced-motion`); a repaint starts each bar at the last width of its kind, so keep `_settleLocalModelBars()` after every body write. The figure beside it is an estimate and says "about" in whole GB (`localModelWholeGb()` on the server's text); only the download figure keeps tabular figures. ⚠️ The rows are the server's pick first, then the other models by download size, largest first (`localModelCandidates()`): the server's alternatives are its ranking, which no column shows. ⚠️ The download bar means ONE thing, the model download: at the start while the runtime is fetched, gone once the model is in (the step list carries verify, agent and connect); the first paint after an install finished shows "Start a session" alone (`justInstalled`), an error on an installed model is the headline with a grey dot, and Remove is quiet everywhere but red in its own question, never the accent. ⚠️ The window polls the GET once a second WHILE OPEN and never otherwise (no SSE event): `closeLocalModelWindow()` clears the timer, a background tab asks nothing, an unchanged answer repaints nothing, and a tick that moved only the download reLine truncated
|
||||
|
||||
**Home-screen session order** (`CodemanSessionOrder` in constants.js, pure): BOTH home screens (phone overview, desktop rail) must order rows through this ONE comparator. Rank `needs` → `error` → `waiting` → `working` → `idle` → `done`. ⚠️ The tiebreak flips: states a session is still IN sort oldest-first, states it has STOPPED sort newest-first. ⚠️ The running group keys off `lastSubmitAt`, never `lastActivityAt` (a working pane repaints constantly). ⚠️ A 0 stamp means unknown and sorts last within its state. Final tiebreak is `orderIndex`, so the list never shuffles. The tab strip itself is NOT sorted by this. Test: `test/session-overview-order.test.ts`. → [architecture-invariants#home-screen-session-order](../../docs/architecture-invariants.md#home-screen-session-order)
|
||||
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
/**
|
||||
* @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;
|
||||
}
|
||||
@@ -1232,11 +1232,6 @@ class CodemanApp {
|
||||
// and then applies the sidebar variant on top — do not call both.
|
||||
this.applySessionListLayout();
|
||||
this.applyTabOrientation();
|
||||
// The home screens' breakpoint listeners, once, before any home surface is
|
||||
// shown: a fold or unfold must swap the surface whichever width the page
|
||||
// loaded at (#702). Both self-gate on a home screen being up.
|
||||
this._wireMobileOverviewBreakpoint?.();
|
||||
this._wireHomeSessionsBreakpoint?.();
|
||||
this.initTabRailResize?.();
|
||||
this.applyMonitorVisibility();
|
||||
this.applyLineageLineSettings?.();
|
||||
|
||||
@@ -126,31 +126,20 @@ Object.assign(CodemanApp.prototype, {
|
||||
this._stopHomeSessionsClock();
|
||||
},
|
||||
|
||||
/**
|
||||
* Re-render only when showing (called from the tab renderer's tail). Asked
|
||||
* again when the debounce fires: `renderHomeSessions()` unhides the column,
|
||||
* so a render queued just before a fold to phone width hid it (#702) would
|
||||
* otherwise bring it back behind the phone overview.
|
||||
*/
|
||||
/** Re-render only when showing (called from the tab renderer's tail). */
|
||||
_refreshHomeSessionsIfVisible() {
|
||||
if (!this.isHomeSessionsVisible()) return;
|
||||
this._debouncedCall(
|
||||
'homeSessions',
|
||||
() => {
|
||||
if (this.isHomeSessionsVisible() && this.shouldShowHomeSessions()) this.renderHomeSessions();
|
||||
},
|
||||
150
|
||||
);
|
||||
this._debouncedCall('homeSessions', () => this.renderHomeSessions(), 150);
|
||||
},
|
||||
|
||||
/**
|
||||
* One delegated click listener for every row. The width listener lives in
|
||||
* `_wireHomeSessionsBreakpoint()`, installed at startup.
|
||||
* One delegated click listener for every row, plus a width listener so
|
||||
* resizing the window while on the home screen adds or drops the column
|
||||
* instead of leaving it overlapping the content it was sized to clear.
|
||||
*/
|
||||
_wireHomeSessions(el) {
|
||||
if (this._homeSessionsWired) return;
|
||||
this._homeSessionsWired = true;
|
||||
this._wireHomeSessionsBreakpoint();
|
||||
|
||||
el.addEventListener('click', (event) => {
|
||||
const target = event.target?.closest?.('[data-hs-action]');
|
||||
@@ -161,29 +150,20 @@ Object.assign(CodemanApp.prototype, {
|
||||
void this.openWebview?.(target.dataset.hsWebview);
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Add or drop the column when the window crosses its width gate, so it never
|
||||
* overlaps the content it was sized to clear. Installed ONCE at startup
|
||||
* (`init()` in app.js) like the phone overview's breakpoint listener, so a
|
||||
* page that loads on a phone and is unfolded to desktop width gets it too
|
||||
* (#702). Idempotent.
|
||||
*/
|
||||
_wireHomeSessionsBreakpoint() {
|
||||
if (this._homeSessionsBreakpointWired || !window.matchMedia) return;
|
||||
this._homeSessionsBreakpointWired = true;
|
||||
const mq = window.matchMedia(`(min-width: ${HOME_SESSIONS_MIN_WIDTH}px)`);
|
||||
const onChange = () => {
|
||||
// Only relevant while the welcome screen is up; entering a session
|
||||
// re-decides through hideWelcome()/showWelcome() anyway.
|
||||
if (this.activeSessionId) return;
|
||||
const overlay = document.getElementById('welcomeOverlay');
|
||||
if (!overlay || !overlay.classList.contains('visible')) return;
|
||||
this.showHomeSessions();
|
||||
};
|
||||
if (mq.addEventListener) mq.addEventListener('change', onChange);
|
||||
else if (mq.addListener) mq.addListener(onChange);
|
||||
if (window.matchMedia) {
|
||||
const mq = window.matchMedia(`(min-width: ${HOME_SESSIONS_MIN_WIDTH}px)`);
|
||||
const onChange = () => {
|
||||
// Only relevant while the welcome screen is up; entering a session
|
||||
// re-decides through hideWelcome()/showWelcome() anyway.
|
||||
if (this.activeSessionId) return;
|
||||
const overlay = document.getElementById('welcomeOverlay');
|
||||
if (!overlay || !overlay.classList.contains('visible')) return;
|
||||
this.showHomeSessions();
|
||||
};
|
||||
if (mq.addEventListener) mq.addEventListener('change', onChange);
|
||||
else if (mq.addListener) mq.addListener(onChange);
|
||||
}
|
||||
},
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
+42
-13
@@ -1434,6 +1434,36 @@
|
||||
'Installing {model}': '正在安装 {model}',
|
||||
'The install of {model} is paused': '{model} 的安装已暂停',
|
||||
'The install of {model} stopped': '{model} 的安装已中止',
|
||||
// A switch to a model that is on this Mac already: nothing is downloaded.
|
||||
'Switching to {model}': '正在切换到 {model}',
|
||||
'The switch to {model} is paused': '切换到 {model} 的操作已暂停',
|
||||
'The switch to {model} stopped': '切换到 {model} 的操作已中止',
|
||||
'The switch waits until you continue.': '切换会等你点击继续后再进行。',
|
||||
'You can close this window. The switch continues while Codeman is running.':
|
||||
'可以关闭此窗口。只要 {name} 在运行,切换就会继续。',
|
||||
// The models on this Mac: the list of the installed screen, its states and its two actions.
|
||||
'Models on this Mac': '此 Mac 上的模型',
|
||||
'On disk': '占用磁盘',
|
||||
'{context} tokens': '{context} 个令牌',
|
||||
'In use': '使用中',
|
||||
Ready: '就绪',
|
||||
Changed: '已变化',
|
||||
Missing: '已缺失',
|
||||
'Not checked': '未检查',
|
||||
'On this Mac': '已在此 Mac 上',
|
||||
Use: '使用',
|
||||
'Use {model}': '使用 {model}',
|
||||
'Use this model': '使用此模型',
|
||||
'Delete {model}': '删除 {model}',
|
||||
'Delete {model} from this Mac?': '要从此 Mac 删除 {model} 吗?',
|
||||
'This frees {size} GB.': '这会释放 {size} GB。',
|
||||
'Downloaded models use {size} GB on this Mac.': '已下载的模型在此 Mac 上占用 {size} GB。',
|
||||
'Press Start to delete a single model.': '点击“启动”后才能删除单个模型。',
|
||||
'Add a model': '添加模型',
|
||||
'Delete the downloaded models from this Mac?': '要从此 Mac 删除已下载的模型吗?',
|
||||
'Models from an earlier install still use {size} GB on this Mac.':
|
||||
'之前安装留下的模型仍在此 Mac 上占用 {size} GB。',
|
||||
'Delete them': '删除它们',
|
||||
'{model} is running': '{model} 正在运行',
|
||||
'{model} is installed and stopped': '{model} 已安装,当前已停止',
|
||||
'Checking this Mac': '正在检查此 Mac',
|
||||
@@ -1578,9 +1608,13 @@
|
||||
'Codeman could not save the connection to the model.': '{name} 无法保存与模型的连接。',
|
||||
'The model on this Mac changed since it was installed. Remove it and install it again.':
|
||||
'此 Mac 上的模型在安装后发生了变化。请移除后重新安装。',
|
||||
'The model on this Mac is being changed and is not ready. Finish or undo that in the local model window first.':
|
||||
'此 Mac 上的模型正在更换,尚未就绪。请先在本地模型窗口中完成或撤销这一操作。',
|
||||
'The install stopped on an unexpected error.': '安装因意外错误而中止。',
|
||||
'An install is already running.': '已有一个安装正在进行。',
|
||||
'A model is already installed on this Mac. Remove it first.': '此 Mac 上已安装了一个模型。请先移除它。',
|
||||
'This model is already in use on this Mac.': '此 Mac 已在使用这个模型。',
|
||||
'{model} is the model in use. Switch to another model first, or remove the local model.':
|
||||
'{model} 是正在使用的模型。请先切换到其他模型,或移除本地模型。',
|
||||
'The download is {size} GB now. Check the sizes and confirm again.':
|
||||
'现在的下载大小为 {size} GB。请核对大小后再次确认。',
|
||||
'Sessions are still using this model: {sessions}. Close them first.':
|
||||
@@ -1590,8 +1624,13 @@
|
||||
'目前不为此 Mac 提供这一上下文大小下的该模型。',
|
||||
'This coding agent is not offered for this model.': '该模型不提供这个编程智能体。',
|
||||
'{cli} is not installed and cannot be installed from here.': '{cli} 尚未安装,也无法从这里安装。',
|
||||
'Only {model} is installed on this Mac. Remove it and install another model first.':
|
||||
'此 Mac 上只安装了 {model}。请先移除它,再安装其他模型。',
|
||||
'Only {model} is in use on this Mac. Switch models in the local model window first.':
|
||||
'此 Mac 目前只使用 {model}。请先在本地模型窗口中切换模型。',
|
||||
'This model is not one of the checked models on this Mac.': '该模型不在此 Mac 上已检查的模型之列。',
|
||||
'This model is not on this Mac.': '此 Mac 上没有这个模型。',
|
||||
'{cli} is not installed on this Mac. Install this model again to choose a coding agent.':
|
||||
'此 Mac 上没有安装 {cli}。请重新安装此模型,以便选择编程智能体。',
|
||||
'{model} could not be deleted from this Mac.': '无法从此 Mac 删除 {model}。',
|
||||
// What the endpoint routes answer for the record of the model on this Mac (shown as a toast).
|
||||
'This endpoint belongs to the model Codeman runs on this Mac. Change or remove it in the local model window.':
|
||||
'此端点属于 {name} 在此 Mac 上运行的模型。请在本地模型窗口中更改或移除它。',
|
||||
@@ -1688,11 +1727,6 @@
|
||||
// The file name is user text: it passes through untranslated.
|
||||
[/^Downloaded (.+) to Downloads$/, (_m, name) => `已将 ${name} 下载到“下载”文件夹`],
|
||||
[/^Download of (.+) failed$/, (_m, name) => `下载 ${name} 失败`],
|
||||
// The Files panel's delete button (panels-ui.js): the path is user text, and the server's
|
||||
// reason passes through untranslated.
|
||||
[/^Delete the file ([\s\S]+)\? This cannot be undone\.$/, (_m, path) => `删除文件 ${path}?此操作无法撤销。`],
|
||||
[/^File deleted: ([\s\S]+)$/, (_m, path) => `已删除文件:${path}`],
|
||||
[/^Delete failed: ([\s\S]+)$/, (_m, why) => `删除失败:${why}`],
|
||||
// Group names are user text: they pass through untranslated.
|
||||
[/^Move to "(.+)"$/, (_m, group) => `移到“${group}”`],
|
||||
// Session names are user text too.
|
||||
@@ -1739,11 +1773,6 @@
|
||||
(_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} 正在终端中等待你的回答`],
|
||||
|
||||
@@ -3456,20 +3456,6 @@
|
||||
<option value="compose">Compose dialog</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="set-row has-field" data-search="voice silence pause timeout manual stop recording">
|
||||
<div class="set-row-text">
|
||||
<label class="set-row-label" for="voiceSilenceTimeout">Stop after silence</label>
|
||||
<span class="set-row-desc">Allow time to think between phrases. Manual keeps listening until you tap the mic again.</span>
|
||||
</div>
|
||||
<select id="voiceSilenceTimeout" class="set-select">
|
||||
<option value="default">Provider default</option>
|
||||
<option value="3">3 seconds</option>
|
||||
<option value="6">6 seconds</option>
|
||||
<option value="15">15 seconds</option>
|
||||
<option value="30">30 seconds</option>
|
||||
<option value="0">Manual</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="set-row has-field" data-search="voice language">
|
||||
<div class="set-row-text">
|
||||
<span class="set-row-label">Language</span>
|
||||
@@ -3586,13 +3572,6 @@
|
||||
</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>
|
||||
|
||||
|
||||
@@ -74,8 +74,6 @@ const PathPicker = {
|
||||
_previewOverlay: null,
|
||||
_previewRequestSequence: 0,
|
||||
_previewPreviousFocus: null,
|
||||
_previewImageCleanup: null,
|
||||
_previewImageZoom: null,
|
||||
_showHidden: false,
|
||||
_sortMode: PATH_PICKER_DEFAULT_SORT,
|
||||
|
||||
@@ -565,11 +563,6 @@ const PathPicker = {
|
||||
<strong class="path-preview-title"></strong>
|
||||
<span class="path-preview-path"></span>
|
||||
</div>
|
||||
<div class="path-preview-zoom" hidden aria-label="Image zoom controls">
|
||||
<button type="button" data-zoom="out" aria-label="Zoom out">−</button>
|
||||
<button type="button" data-zoom="fit" aria-label="Fit image">Fit</button>
|
||||
<button type="button" data-zoom="in" aria-label="Zoom in">+</button>
|
||||
</div>
|
||||
<a class="path-preview-open" target="_blank" rel="noopener noreferrer">Open</a>
|
||||
<button type="button" class="path-preview-close" aria-label="Close preview">×</button>
|
||||
</div>
|
||||
@@ -587,30 +580,13 @@ const PathPicker = {
|
||||
|
||||
const body = overlay.querySelector('.path-preview-body');
|
||||
if (entry.previewKind === 'image') {
|
||||
const zoomControls = overlay.querySelector('.path-preview-zoom');
|
||||
body.classList.add('path-preview-image-viewport');
|
||||
const image = document.createElement('img');
|
||||
image.className = 'path-preview-image';
|
||||
image.alt = entry.name;
|
||||
image.draggable = false;
|
||||
// Both handlers check the sequence like the text branch: an image whose preview was already
|
||||
// replaced can still finish loading, and `this._previewOverlay` and the zoom state are then
|
||||
// the newer preview's. The controls show only once there is an image to zoom.
|
||||
image.addEventListener('load', () => {
|
||||
if (requestSequence !== this._previewRequestSequence) return;
|
||||
body.querySelector('.path-preview-loading')?.remove();
|
||||
zoomControls.hidden = false;
|
||||
this._setPreviewImageZoom(1);
|
||||
});
|
||||
image.addEventListener('error', () => {
|
||||
if (requestSequence !== this._previewRequestSequence) return;
|
||||
zoomControls.hidden = true;
|
||||
this._previewImageCleanup?.();
|
||||
this.showPreviewError('Image preview failed to load');
|
||||
});
|
||||
image.addEventListener('load', () => body.querySelector('.path-preview-loading')?.remove());
|
||||
image.addEventListener('error', () => this.showPreviewError('Image preview failed to load'));
|
||||
image.src = previewUrl;
|
||||
body.appendChild(image);
|
||||
this._bindPreviewImageGestures(image, body, zoomControls);
|
||||
} else if (entry.previewKind === 'text') {
|
||||
fetch(previewUrl)
|
||||
.then(async (response) => {
|
||||
@@ -654,142 +630,8 @@ const PathPicker = {
|
||||
body.replaceChildren(error);
|
||||
},
|
||||
|
||||
_setPreviewImageZoom(scale, focalPoint) {
|
||||
const state = this._previewImageZoom;
|
||||
if (!state) return;
|
||||
const nextScale = Math.max(1, Math.min(6, scale));
|
||||
if (focalPoint) {
|
||||
const rect = state.body.getBoundingClientRect();
|
||||
const qx = focalPoint.x - (rect.left + rect.width / 2);
|
||||
const qy = focalPoint.y - (rect.top + rect.height / 2);
|
||||
const ratio = nextScale / state.scale;
|
||||
state.x = qx + (state.x - qx) * ratio;
|
||||
state.y = qy + (state.y - qy) * ratio;
|
||||
}
|
||||
state.scale = nextScale;
|
||||
this._clampPreviewImagePan();
|
||||
state.image.style.transform = `translate(${state.x}px, ${state.y}px) scale(${state.scale})`;
|
||||
const fitButton = state.controls.querySelector('[data-zoom="fit"]');
|
||||
if (fitButton) fitButton.textContent = `${Math.round(state.scale * 100)}%`;
|
||||
state.image.classList.toggle('is-zoomed', state.scale > 1);
|
||||
},
|
||||
|
||||
/** Size the image is drawn at before zoom: `object-fit: scale-down` in the viewport box, so never
|
||||
* larger than its natural size. The box until the image has loaded (no natural size yet). */
|
||||
_previewImageFitSize(state, rect) {
|
||||
const naturalWidth = state.image.naturalWidth;
|
||||
const naturalHeight = state.image.naturalHeight;
|
||||
if (!naturalWidth || !naturalHeight) return { width: rect.width, height: rect.height };
|
||||
const fit = Math.min(1, rect.width / naturalWidth, rect.height / naturalHeight);
|
||||
return { width: naturalWidth * fit, height: naturalHeight * fit };
|
||||
},
|
||||
|
||||
/** Keep the zoomed image covering the viewport: it may move until its edge reaches the viewport's
|
||||
* edge, never further, and an axis on which it is still smaller than the viewport stays centred.
|
||||
* Clamping to the image box instead let a small or letterboxed image be panned out of view. */
|
||||
_clampPreviewImagePan() {
|
||||
const state = this._previewImageZoom;
|
||||
if (!state) return;
|
||||
const rect = state.body.getBoundingClientRect();
|
||||
const fitted = this._previewImageFitSize(state, rect);
|
||||
const maxX = Math.max(0, (fitted.width * state.scale - rect.width) / 2);
|
||||
const maxY = Math.max(0, (fitted.height * state.scale - rect.height) / 2);
|
||||
state.x = Math.max(-maxX, Math.min(maxX, state.x));
|
||||
state.y = Math.max(-maxY, Math.min(maxY, state.y));
|
||||
},
|
||||
|
||||
/** Zoom factor for one wheel event, proportional to `deltaY` (d3-zoom's scale: about 15% per
|
||||
* 100px mouse notch, ten times that for ctrl+wheel, which is how Chrome and Firefox deliver a
|
||||
* trackpad pinch), so a trackpad's stream of small deltas zooms smoothly instead of 20% per
|
||||
* event. Clamped per event so one large delta cannot jump to the maximum; no vertical delta
|
||||
* (a sideways scroll) zooms nothing. */
|
||||
_previewWheelZoomFactor(event) {
|
||||
const deltaY = Number(event?.deltaY) || 0;
|
||||
if (!deltaY) return 1;
|
||||
const perUnit = event.deltaMode === 1 ? 0.05 : event.deltaMode ? 1 : 0.002;
|
||||
const exponent = -deltaY * perUnit * (event.ctrlKey ? 10 : 1);
|
||||
return 2 ** Math.max(-0.5, Math.min(0.5, exponent));
|
||||
},
|
||||
|
||||
_bindPreviewImageGestures(image, body, controls) {
|
||||
this._previewImageZoom = { image, body, controls, scale: 1, x: 0, y: 0, pointers: new Map(), gesture: null };
|
||||
const state = this._previewImageZoom;
|
||||
const cleanups = [];
|
||||
const listen = (target, name, handler, options) => {
|
||||
target.addEventListener(name, handler, options);
|
||||
cleanups.push(() => target.removeEventListener(name, handler, options));
|
||||
};
|
||||
const pointer = (event) => ({ x: event.clientX, y: event.clientY });
|
||||
const midpoint = (a, b) => ({ x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 });
|
||||
const distance = (a, b) => Math.hypot(a.x - b.x, a.y - b.y);
|
||||
const startGesture = () => {
|
||||
const points = Array.from(state.pointers.values());
|
||||
if (points.length > 1) {
|
||||
state.gesture = {
|
||||
kind: 'pinch',
|
||||
scale: state.scale,
|
||||
distance: Math.max(1, distance(points[0], points[1])),
|
||||
midpoint: midpoint(points[0], points[1]),
|
||||
};
|
||||
} else if (points.length === 1) {
|
||||
state.gesture = { kind: 'pan', point: points[0], x: state.x, y: state.y };
|
||||
}
|
||||
};
|
||||
listen(image, 'pointerdown', (event) => {
|
||||
event.preventDefault();
|
||||
image.setPointerCapture?.(event.pointerId);
|
||||
state.pointers.set(event.pointerId, pointer(event));
|
||||
startGesture();
|
||||
});
|
||||
listen(image, 'pointermove', (event) => {
|
||||
if (!state.pointers.has(event.pointerId) || !state.gesture) return;
|
||||
event.preventDefault();
|
||||
state.pointers.set(event.pointerId, pointer(event));
|
||||
if (state.pointers.size > 1) {
|
||||
const points = Array.from(state.pointers.values());
|
||||
this._setPreviewImageZoom(
|
||||
state.gesture.scale * distance(points[0], points[1]) / state.gesture.distance,
|
||||
state.gesture.midpoint
|
||||
);
|
||||
} else if (state.gesture.kind === 'pan' && state.scale > 1) {
|
||||
const point = state.pointers.values().next().value;
|
||||
state.x = state.gesture.x + point.x - state.gesture.point.x;
|
||||
state.y = state.gesture.y + point.y - state.gesture.point.y;
|
||||
this._setPreviewImageZoom(state.scale);
|
||||
}
|
||||
});
|
||||
const endPointer = (event) => {
|
||||
state.pointers.delete(event.pointerId);
|
||||
startGesture();
|
||||
};
|
||||
listen(image, 'pointerup', endPointer);
|
||||
listen(image, 'pointercancel', endPointer);
|
||||
listen(image, 'lostpointercapture', endPointer);
|
||||
listen(image, 'dblclick', (event) => {
|
||||
event.preventDefault();
|
||||
this._setPreviewImageZoom(state.scale > 1 ? 1 : 2, pointer(event));
|
||||
});
|
||||
listen(body, 'wheel', (event) => {
|
||||
event.preventDefault();
|
||||
const factor = this._previewWheelZoomFactor(event);
|
||||
if (factor !== 1) this._setPreviewImageZoom(state.scale * factor, pointer(event));
|
||||
}, { passive: false });
|
||||
listen(controls, 'click', (event) => {
|
||||
const button = event.target.closest('[data-zoom]');
|
||||
if (!button) return;
|
||||
if (button.dataset.zoom === 'fit') this._setPreviewImageZoom(1);
|
||||
else this._setPreviewImageZoom(state.scale * (button.dataset.zoom === 'in' ? 1.5 : 1 / 1.5));
|
||||
});
|
||||
this._previewImageCleanup = () => {
|
||||
cleanups.forEach((cleanup) => cleanup());
|
||||
this._previewImageZoom = null;
|
||||
this._previewImageCleanup = null;
|
||||
};
|
||||
},
|
||||
|
||||
closePreview(restoreFocus = true) {
|
||||
this._previewRequestSequence += 1;
|
||||
this._previewImageCleanup?.();
|
||||
this._previewOverlay?.remove();
|
||||
this._previewOverlay = null;
|
||||
const previousFocus = this._previewPreviousFocus;
|
||||
|
||||
@@ -7,10 +7,12 @@
|
||||
* drives the install verbs:
|
||||
*
|
||||
* POST /api/local-model/install { modelId, cli, context, acknowledgedBytes }
|
||||
* POST /api/local-model/use { modelId }
|
||||
* POST /api/local-model/cancel
|
||||
* POST /api/local-model/resume { newPort? }
|
||||
* POST /api/local-model/runtime { action: 'start' | 'stop', newPort? }
|
||||
* DELETE /api/local-model { removeModel }
|
||||
* DELETE /api/local-model/models { modelId }
|
||||
*
|
||||
* ## Three surfaces, one window
|
||||
*
|
||||
@@ -25,14 +27,17 @@
|
||||
* | Screen | When |
|
||||
* |-------------|-------------------------------------------------------------------|
|
||||
* | `choose` | idle: the memory bar, the models that fit as a radio group, the |
|
||||
* | | coding agent, "Install on this Mac" |
|
||||
* | | coding agent, "Install on this Mac". Also from an installed |
|
||||
* | | model ("Add a model"): a row that is on this Mac already says so |
|
||||
* | | and is switched to without a download |
|
||||
* | `confirm` | after that button: the download as a table with its total, where |
|
||||
* | | it goes |
|
||||
* | `progress` | installing: the download bar, the steps, Cancel |
|
||||
* | `paused` | paused (Cancel or a server restart): Continue or Remove |
|
||||
* | `failed` | failed: the server's sentence and the ONE action that helps |
|
||||
* | `installed` | installed: running or stopped, Start a session, Stop/Start, Remove |
|
||||
* | | (right after an install: Start a session alone) |
|
||||
* | `installed` | installed: running or stopped, the models on this Mac with their |
|
||||
* | | size on disk (Use, Delete), Add a model, Start a session, |
|
||||
* | | Stop/Start, Remove (right after an install: Start a session alone) |
|
||||
* | `none` | idle and nothing fits: the hardware line, the reason, Close |
|
||||
*
|
||||
* The memory bar is the one strong element: the memory this Mac can give a model, filled to
|
||||
@@ -49,6 +54,13 @@
|
||||
* size, largest first, which is a column the reader can see. The server's own order of the
|
||||
* alternatives is its ranking (what it would offer next), not a reading order.
|
||||
*
|
||||
* The models on this Mac (`install.library`) are a list of their own, on the installed screen
|
||||
* and on a paused or failed one that has a model to go back to: the model in use first, each
|
||||
* with its size on disk and the two things a row can offer, "Use" (a switch, nothing is
|
||||
* downloaded) and "Delete" (asked once, in the row). Under it the disk use of all downloaded
|
||||
* models (`install.store`) and "Add a model". A row has a button only for an action the server
|
||||
* lists for THAT row (`row.actions`).
|
||||
*
|
||||
* ## Polling
|
||||
*
|
||||
* The window asks the GET once a second WHILE IT IS OPEN, and never otherwise (there is no SSE
|
||||
@@ -100,6 +112,9 @@ const LOCAL_MODEL_MAX_AGE_MS = 60000;
|
||||
/** How often the open window asks where the install stands. */
|
||||
const LOCAL_MODEL_POLL_MS = 1000;
|
||||
|
||||
/** What `confirmDelete` holds while the question is about everything an earlier Remove kept. No model id has a space. */
|
||||
const LOCAL_MODEL_STORE_KEY = 'the store';
|
||||
|
||||
/** The tag kinds of the server's pick (`CandidateTagKind`): the ones drawn in the accent colour. */
|
||||
const LOCAL_MODEL_ACCENT_TAGS = ['tested', 'best', 'best-tight'];
|
||||
|
||||
@@ -202,6 +217,29 @@ function localModelCandidates(status) {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the job of `install` is a switch to a model that is on this Mac already: its steps
|
||||
* hold no download. The steps are the server's; nothing is compared with a model or an agent.
|
||||
*/
|
||||
function localModelIsSwitch(install) {
|
||||
const steps = Array.isArray(install.steps) ? install.steps : [];
|
||||
return steps.length > 0 && !steps.includes('model');
|
||||
}
|
||||
|
||||
/** The rows of `install.library`: the models on this Mac. A payload without the list has none. */
|
||||
function localModelLibrary(install) {
|
||||
return (Array.isArray(install.library) ? install.library : []).filter((row) => row && typeof row.id === 'string');
|
||||
}
|
||||
|
||||
/** What a library row's state is called, and the tag class that colours it. */
|
||||
function localModelLibraryTag(row) {
|
||||
if (row.active === true) return { text: localModelT('In use'), kind: 'inuse' };
|
||||
if (row.status === 'ready') return { text: localModelT('Ready'), kind: 'ready' };
|
||||
if (row.status === 'changed') return { text: localModelT('Changed'), kind: 'changed' };
|
||||
if (row.status === 'missing') return { text: localModelT('Missing'), kind: 'missing' };
|
||||
return { text: localModelT('Not checked'), kind: 'unverified' };
|
||||
}
|
||||
|
||||
/**
|
||||
* The coding agents a row can run with. A payload without the list means the one agent of the
|
||||
* row's flat fields.
|
||||
@@ -516,6 +554,11 @@ Object.assign(CodemanApp.prototype, {
|
||||
/** The Remove question is open, and whether the downloaded model goes too. */
|
||||
confirmRemove: false,
|
||||
removeModel: true,
|
||||
/**
|
||||
* The Delete question of one row is open: that row's model id, or `LOCAL_MODEL_STORE_KEY`
|
||||
* for the downloaded models an earlier Remove kept.
|
||||
*/
|
||||
confirmDelete: null,
|
||||
/**
|
||||
* The install finished while this window was open: the screen that ends it shows
|
||||
* "Start a session" alone. Stop and Remove belong to the window opened later.
|
||||
@@ -543,9 +586,10 @@ Object.assign(CodemanApp.prototype, {
|
||||
const install = localModelInstall(this._localModelStatus);
|
||||
const selection = this._localModelSelection();
|
||||
if (install.state === 'installing') return 'progress';
|
||||
if (install.state === 'installed') return 'installed';
|
||||
const canInstall = install.actions.includes('install') && !!selection;
|
||||
// The chooser is also where a second model is added to an installed one.
|
||||
if (canInstall && (ui.view === 'choose' || ui.view === 'confirm')) return ui.view;
|
||||
if (install.state === 'installed') return 'installed';
|
||||
if (install.state === 'paused') return 'paused';
|
||||
if (install.state === 'failed') return 'failed';
|
||||
return selection ? 'choose' : 'none';
|
||||
@@ -572,6 +616,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
notice: null,
|
||||
expected: null,
|
||||
confirmRemove: false,
|
||||
confirmDelete: null,
|
||||
justInstalled: false,
|
||||
});
|
||||
this._forgetLocalModelPaint();
|
||||
@@ -690,10 +735,10 @@ Object.assign(CodemanApp.prototype, {
|
||||
this.renderLocalModel();
|
||||
},
|
||||
|
||||
/** Back to the chooser (from the confirm screen, or to pick another model after a stop). */
|
||||
/** Back to the chooser (from the confirm screen, to pick another model after a stop, or to add one). */
|
||||
chooseLocalModel(modelId) {
|
||||
const ui = this._localModelWindowState();
|
||||
Object.assign(ui, { view: 'choose', notice: null, expected: null, confirmRemove: false });
|
||||
Object.assign(ui, { view: 'choose', notice: null, expected: null, confirmRemove: false, confirmDelete: null });
|
||||
if (typeof modelId === 'string') {
|
||||
ui.modelId = modelId;
|
||||
ui.cli = null;
|
||||
@@ -749,6 +794,57 @@ Object.assign(CodemanApp.prototype, {
|
||||
this.renderLocalModel();
|
||||
},
|
||||
|
||||
/**
|
||||
* "Use": switch to a model that is on this Mac already. Nothing is downloaded, so there is
|
||||
* no confirm screen: the server loads and checks the model again and connects it.
|
||||
*/
|
||||
async useLocalModel(modelId) {
|
||||
const ui = this._localModelWindowState();
|
||||
ui.confirmDelete = null;
|
||||
const result = await this._localModelVerb('POST', '/api/local-model/use', { modelId });
|
||||
// Started, or refused with the server's sentence: either way the state's own screen says it.
|
||||
if (result.ok || result.conflict) ui.view = 'auto';
|
||||
this.renderLocalModel();
|
||||
},
|
||||
|
||||
/** "Delete" of one row: first the question in the row, then (with `confirmed`) the delete. */
|
||||
async deleteLocalModelEntry(modelId, { confirmed = false } = {}) {
|
||||
const ui = this._localModelWindowState();
|
||||
if (!confirmed) {
|
||||
Object.assign(ui, { confirmDelete: modelId, confirmRemove: false, notice: null });
|
||||
this.renderLocalModel();
|
||||
// On the safe answer, whatever had the focus (a click does not move it in every browser).
|
||||
this._focusLocalModelWindow('delete-no');
|
||||
return;
|
||||
}
|
||||
await this._localModelVerb('DELETE', '/api/local-model/models', { modelId });
|
||||
ui.confirmDelete = null;
|
||||
this.renderLocalModel();
|
||||
},
|
||||
|
||||
/** "Delete them": the downloaded models an earlier Remove kept, while nothing is installed. */
|
||||
async deleteLocalModelStore({ confirmed = false } = {}) {
|
||||
const ui = this._localModelWindowState();
|
||||
if (!confirmed) {
|
||||
Object.assign(ui, { confirmDelete: LOCAL_MODEL_STORE_KEY, notice: null });
|
||||
this.renderLocalModel();
|
||||
this._focusLocalModelWindow('delete-no');
|
||||
return;
|
||||
}
|
||||
await this._localModelVerb('DELETE', '/api/local-model', { removeModel: true });
|
||||
ui.confirmDelete = null;
|
||||
this.renderLocalModel();
|
||||
},
|
||||
|
||||
/** Leave a Delete question without deleting. The focus goes back to the button that asked. */
|
||||
keepLocalModelEntry() {
|
||||
const ui = this._localModelWindowState();
|
||||
const asked = ui.confirmDelete;
|
||||
Object.assign(ui, { confirmDelete: null, notice: null });
|
||||
this.renderLocalModel();
|
||||
if (asked) this._focusLocalModelWindow(asked === LOCAL_MODEL_STORE_KEY ? 'store-delete' : `delete:${asked}`);
|
||||
},
|
||||
|
||||
cancelLocalModelInstall() {
|
||||
return this._localModelVerb('POST', '/api/local-model/cancel');
|
||||
},
|
||||
@@ -767,7 +863,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
async removeLocalModel({ confirmed = false } = {}) {
|
||||
const ui = this._localModelWindowState();
|
||||
if (!confirmed) {
|
||||
Object.assign(ui, { confirmRemove: true, notice: null });
|
||||
Object.assign(ui, { confirmRemove: true, confirmDelete: null, notice: null });
|
||||
this.renderLocalModel();
|
||||
return;
|
||||
}
|
||||
@@ -829,9 +925,20 @@ Object.assign(CodemanApp.prototype, {
|
||||
const status = this._localModelStatus;
|
||||
const install = localModelInstall(status);
|
||||
const model = install.model ? String(install.model.label || install.model.id || '') : '';
|
||||
if (install.state === 'installing') return localModelT('Installing {model}', { model });
|
||||
if (install.state === 'paused') return localModelT('The install of {model} is paused', { model });
|
||||
if (install.state === 'failed') return localModelT('The install of {model} stopped', { model });
|
||||
const switching = localModelIsSwitch(install);
|
||||
if (install.state === 'installing') {
|
||||
return switching ? localModelT('Switching to {model}', { model }) : localModelT('Installing {model}', { model });
|
||||
}
|
||||
if (install.state === 'paused') {
|
||||
return switching
|
||||
? localModelT('The switch to {model} is paused', { model })
|
||||
: localModelT('The install of {model} is paused', { model });
|
||||
}
|
||||
if (install.state === 'failed') {
|
||||
return switching
|
||||
? localModelT('The switch to {model} stopped', { model })
|
||||
: localModelT('The install of {model} stopped', { model });
|
||||
}
|
||||
if (install.state === 'installed') {
|
||||
if (install.running) return localModelT('{model} is running', { model });
|
||||
return localModelT('{model} is installed and stopped', { model });
|
||||
@@ -917,6 +1024,8 @@ Object.assign(CodemanApp.prototype, {
|
||||
foot && tail && tail.classList && (tail.classList.contains('lm-foot') || tail.classList.contains('lm-remove'));
|
||||
body.dataset.screen = screen;
|
||||
body.replaceChildren(...(actions ? parts.slice(0, -1) : parts));
|
||||
// A screen that carries the list of models starts at the top and scrolls (see styles.css).
|
||||
body.dataset.long = body.querySelector('.lm-library') ? 'true' : 'false';
|
||||
if (foot) foot.replaceChildren(...(actions ? [tail] : []));
|
||||
body.scrollTop = scrolled;
|
||||
this._settleLocalModelBars(body);
|
||||
@@ -1031,6 +1140,12 @@ Object.assign(CodemanApp.prototype, {
|
||||
tag.classList.add(`lm-tag-${candidate.tag.kind}`);
|
||||
tagSlot.appendChild(tag);
|
||||
}
|
||||
// Already on this Mac: choosing it switches to it, and nothing is downloaded.
|
||||
if (candidate.library === 'active') {
|
||||
tagSlot.appendChild(localModelEl('span', 'lm-tag lm-tag-inuse', localModelT('In use')));
|
||||
} else if (candidate.library === 'ready') {
|
||||
tagSlot.appendChild(localModelEl('span', 'lm-tag lm-tag-ready', localModelT('On this Mac')));
|
||||
}
|
||||
row.appendChild(tagSlot);
|
||||
const speed = localModelEl('span', 'lm-model-speed', localModelSpeedText(candidate));
|
||||
const gauge = localModelEl(
|
||||
@@ -1153,6 +1268,10 @@ Object.assign(CodemanApp.prototype, {
|
||||
if (hints.length) runs.appendChild(localModelEl('span', 'lm-runs-hint', hints.join(' ')));
|
||||
parts.push(runs);
|
||||
|
||||
// Nothing is installed, and an earlier Remove kept its downloads: what they take, and the way to free it.
|
||||
const leftovers = this._localModelStoreEl(install);
|
||||
if (leftovers) parts.push(leftovers);
|
||||
|
||||
const notice = this._localModelNoticeEl();
|
||||
if (notice) parts.push(notice);
|
||||
|
||||
@@ -1160,13 +1279,25 @@ Object.assign(CodemanApp.prototype, {
|
||||
const promise = localModelT('No account and no API key. The model runs only on this Mac.');
|
||||
foot.appendChild(localModelEl('p', 'lm-foot-note', promise));
|
||||
const buttons = localModelEl('div', 'lm-buttons');
|
||||
// Reached from a paused or stopped install: the way back to it.
|
||||
// Reached from a paused or stopped install, or from an installed model: the way back to it.
|
||||
if (install.state !== 'idle') {
|
||||
buttons.appendChild(
|
||||
localModelButton(localModelT('Back'), { key: 'back', onClick: () => this.leaveLocalModelChooser() })
|
||||
);
|
||||
}
|
||||
if (install.actions.includes('install')) {
|
||||
if (candidate.library === 'active') {
|
||||
// The model that runs now: nothing to do with it here.
|
||||
buttons.appendChild(localModelButton(localModelT('In use'), { key: 'primary', primary: true, disabled: true }));
|
||||
} else if (candidate.library === 'ready' && install.actions.includes('use')) {
|
||||
buttons.appendChild(
|
||||
localModelButton(localModelT('Use this model'), {
|
||||
key: 'primary',
|
||||
primary: true,
|
||||
disabled: ui.busy,
|
||||
onClick: () => void this.useLocalModel(candidate.modelId),
|
||||
})
|
||||
);
|
||||
} else if (install.actions.includes('install')) {
|
||||
buttons.appendChild(
|
||||
localModelButton(localModelT('Install on this Mac'), {
|
||||
key: 'primary',
|
||||
@@ -1181,6 +1312,189 @@ Object.assign(CodemanApp.prototype, {
|
||||
return parts;
|
||||
},
|
||||
|
||||
/**
|
||||
* The models on this Mac: one row per model of `install.library` with its name, the context
|
||||
* it was checked with, its state, its size on disk and what the server offers for it. A row
|
||||
* whose Delete was pressed is followed by the question, once, in place. `null` when there is
|
||||
* no row, or (with `onlyWithActions`, for a paused or failed job) no row offers anything.
|
||||
*/
|
||||
_localModelLibraryEl(install, { onlyWithActions = false } = {}) {
|
||||
const ui = this._localModelWindowState();
|
||||
const rows = localModelLibrary(install);
|
||||
const offers = (row, action) => Array.isArray(row.actions) && row.actions.includes(action);
|
||||
if (!rows.length) return null;
|
||||
if (onlyWithActions && !rows.some((row) => offers(row, 'use') || offers(row, 'delete'))) return null;
|
||||
|
||||
const section = localModelEl('div', 'lm-library');
|
||||
const list = localModelEl('div', 'lm-lib-list');
|
||||
list.setAttribute('role', 'list');
|
||||
list.setAttribute('aria-labelledby', 'localModelLibraryLabel');
|
||||
const caption = localModelEl('div', 'lm-lib-caption');
|
||||
const label = localModelEl('span', 'lm-lib-caption-label', localModelT('Models on this Mac'));
|
||||
label.id = 'localModelLibraryLabel';
|
||||
caption.appendChild(label);
|
||||
caption.appendChild(localModelEl('span', 'lm-lib-caption-size', localModelT('On disk')));
|
||||
list.appendChild(caption);
|
||||
|
||||
for (const row of rows) {
|
||||
const name = String(row.label || row.id);
|
||||
const item = localModelEl('div', row.active === true ? 'lm-lib-row lm-lib-row-on' : 'lm-lib-row');
|
||||
item.setAttribute('role', 'listitem');
|
||||
const what = localModelEl('div', 'lm-lib-name');
|
||||
what.appendChild(localModelEl('span', 'lm-lib-label', name));
|
||||
if (Number.isFinite(row.context)) {
|
||||
what.appendChild(
|
||||
localModelEl(
|
||||
'span',
|
||||
'lm-lib-meta',
|
||||
localModelT('{context} tokens', { context: localModelCount(row.context) })
|
||||
)
|
||||
);
|
||||
}
|
||||
item.appendChild(what);
|
||||
const tag = localModelLibraryTag(row);
|
||||
const tagSlot = localModelEl('span', 'lm-lib-tag');
|
||||
tagSlot.appendChild(localModelEl('span', `lm-tag lm-tag-${tag.kind}`, tag.text));
|
||||
item.appendChild(tagSlot);
|
||||
item.appendChild(localModelEl('span', 'lm-lib-size', row.sizeGb ? localModelGb(row.sizeGb) : ''));
|
||||
|
||||
const asking = ui.confirmDelete === row.id && offers(row, 'delete');
|
||||
const actions = localModelEl('span', 'lm-lib-actions');
|
||||
// While the row's question is open its buttons keep their place and are out of reach,
|
||||
// so the column of sizes does not move when the question opens.
|
||||
if (asking) {
|
||||
actions.classList.add('lm-lib-actions-away');
|
||||
actions.setAttribute('aria-hidden', 'true');
|
||||
}
|
||||
if (offers(row, 'use')) {
|
||||
const use = localModelButton(localModelT('Use'), {
|
||||
key: `use:${row.id}`,
|
||||
disabled: ui.busy || asking,
|
||||
onClick: () => void this.useLocalModel(row.id),
|
||||
});
|
||||
use.classList.add('lm-btn-row');
|
||||
use.setAttribute('aria-label', localModelT('Use {model}', { model: name }));
|
||||
actions.appendChild(use);
|
||||
}
|
||||
if (offers(row, 'delete')) {
|
||||
const remove = localModelButton(localModelT('Delete'), {
|
||||
key: `delete:${row.id}`,
|
||||
disabled: ui.busy || asking,
|
||||
onClick: () => void this.deleteLocalModelEntry(row.id),
|
||||
});
|
||||
remove.classList.add('lm-btn-row');
|
||||
remove.setAttribute('aria-label', localModelT('Delete {model}', { model: name }));
|
||||
actions.appendChild(remove);
|
||||
}
|
||||
item.appendChild(actions);
|
||||
list.appendChild(item);
|
||||
|
||||
if (asking) {
|
||||
list.appendChild(
|
||||
this._localModelAskEl({
|
||||
question: localModelT('Delete {model} from this Mac?', { model: name }),
|
||||
detail: row.sizeGb ? localModelT('This frees {size} GB.', { size: row.sizeGb }) : '',
|
||||
onYes: () => void this.deleteLocalModelEntry(row.id, { confirmed: true }),
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
section.appendChild(list);
|
||||
|
||||
const foot = localModelEl('div', 'lm-lib-foot');
|
||||
const notes = localModelEl('div', 'lm-lib-notes');
|
||||
const store = install.store;
|
||||
if (store && Number(store.bytes) > 0 && store.gb) {
|
||||
notes.appendChild(
|
||||
localModelEl('p', 'lm-aside', localModelT('Downloaded models use {size} GB on this Mac.', { size: store.gb }))
|
||||
);
|
||||
}
|
||||
// Only the runtime deletes a model: with it stopped, the rows offer none, and this says why.
|
||||
if (install.state === 'installed' && install.running !== true && rows.length > 1) {
|
||||
notes.appendChild(localModelEl('p', 'lm-aside', localModelT('Press Start to delete a single model.')));
|
||||
}
|
||||
if (notes.childElementCount) foot.appendChild(notes);
|
||||
if (install.state === 'installed' && install.actions.includes('install') && this._localModelSelection()) {
|
||||
const add = localModelButton(localModelT('Add a model'), {
|
||||
key: 'add',
|
||||
disabled: ui.busy,
|
||||
onClick: () => this.chooseLocalModel(),
|
||||
});
|
||||
add.classList.add('lm-btn-row');
|
||||
foot.appendChild(add);
|
||||
}
|
||||
if (foot.childElementCount) section.appendChild(foot);
|
||||
return section;
|
||||
},
|
||||
|
||||
/** A Delete question in place: the question, what it frees, Cancel and the red Delete. */
|
||||
_localModelAskEl({ question, detail, onYes }) {
|
||||
const ui = this._localModelWindowState();
|
||||
const ask = localModelEl('div', 'lm-lib-ask');
|
||||
ask.setAttribute('role', 'group');
|
||||
const text = localModelEl('div', 'lm-lib-ask-text');
|
||||
text.appendChild(localModelEl('p', 'lm-lead', question));
|
||||
if (detail) text.appendChild(localModelEl('p', 'lm-aside', detail));
|
||||
ask.appendChild(text);
|
||||
const buttons = localModelEl('div', 'lm-lib-actions');
|
||||
const no = localModelButton(localModelT('Cancel'), {
|
||||
key: 'delete-no',
|
||||
disabled: ui.busy,
|
||||
onClick: () => this.keepLocalModelEntry(),
|
||||
});
|
||||
no.classList.add('lm-btn-row');
|
||||
const yes = localModelButton(localModelT('Delete'), {
|
||||
key: 'delete-yes',
|
||||
danger: true,
|
||||
disabled: ui.busy,
|
||||
onClick: onYes,
|
||||
});
|
||||
yes.classList.add('lm-btn-row');
|
||||
buttons.appendChild(no);
|
||||
buttons.appendChild(yes);
|
||||
ask.appendChild(buttons);
|
||||
return ask;
|
||||
},
|
||||
|
||||
/**
|
||||
* Nothing is installed and the model store still holds downloads (an earlier Remove kept
|
||||
* them): what they take on disk and "Delete them", asked once in place. `null` unless the
|
||||
* server offers that removal.
|
||||
*/
|
||||
_localModelStoreEl(install) {
|
||||
const ui = this._localModelWindowState();
|
||||
const store = install.store;
|
||||
if (install.state !== 'idle' || !install.actions.includes('remove')) return null;
|
||||
if (!store || !(Number(store.bytes) > 0) || !store.gb) return null;
|
||||
if (ui.confirmDelete === LOCAL_MODEL_STORE_KEY) {
|
||||
const box = localModelEl('div', 'lm-leftovers');
|
||||
box.appendChild(
|
||||
this._localModelAskEl({
|
||||
question: localModelT('Delete the downloaded models from this Mac?'),
|
||||
detail: localModelT('This frees {size} GB.', { size: store.gb }),
|
||||
onYes: () => void this.deleteLocalModelStore({ confirmed: true }),
|
||||
})
|
||||
);
|
||||
return box;
|
||||
}
|
||||
const box = localModelEl('div', 'lm-leftovers lm-lib-foot');
|
||||
box.appendChild(
|
||||
localModelEl(
|
||||
'p',
|
||||
'lm-aside',
|
||||
localModelT('Models from an earlier install still use {size} GB on this Mac.', { size: store.gb })
|
||||
)
|
||||
);
|
||||
const remove = localModelButton(localModelT('Delete them'), {
|
||||
key: 'store-delete',
|
||||
disabled: ui.busy,
|
||||
onClick: () => void this.deleteLocalModelStore(),
|
||||
});
|
||||
remove.classList.add('lm-btn-row');
|
||||
box.appendChild(remove);
|
||||
return box;
|
||||
},
|
||||
|
||||
/** Arrow keys, Home and End move the selection of the radio group, and the focus with it. */
|
||||
_onLocalModelListKey(event) {
|
||||
const selection = this._localModelSelection();
|
||||
@@ -1397,9 +1711,18 @@ Object.assign(CodemanApp.prototype, {
|
||||
const paused = screen === 'paused';
|
||||
const model = install.model ? String(install.model.label || install.model.id || '') : '';
|
||||
const parts = [];
|
||||
const title = paused
|
||||
? localModelT('The install of {model} is paused', { model })
|
||||
: localModelT('Installing {model}', { model });
|
||||
// A switch to a model that is on this Mac already downloads nothing, and says so in its name.
|
||||
const switching = localModelIsSwitch(install);
|
||||
let title;
|
||||
if (switching) {
|
||||
title = paused
|
||||
? localModelT('The switch to {model} is paused', { model })
|
||||
: localModelT('Switching to {model}', { model });
|
||||
} else {
|
||||
title = paused
|
||||
? localModelT('The install of {model} is paused', { model })
|
||||
: localModelT('Installing {model}', { model });
|
||||
}
|
||||
parts.push(localModelEl('p', 'lm-title', title));
|
||||
|
||||
// The download bar takes the place of the memory bar, for as long as something downloads.
|
||||
@@ -1414,19 +1737,26 @@ Object.assign(CodemanApp.prototype, {
|
||||
return parts;
|
||||
}
|
||||
|
||||
// What waits or goes on is the download while there is one, the install after it.
|
||||
// What waits or goes on is the download while there is one, the install (or the switch) after it.
|
||||
const downloading = !!meter;
|
||||
let note;
|
||||
if (paused) {
|
||||
if (paused && switching) {
|
||||
note = localModelT('The switch waits until you continue.');
|
||||
} else if (paused) {
|
||||
note = downloading
|
||||
? localModelT('The download waits until you continue.')
|
||||
: localModelT('The install waits until you continue.');
|
||||
} else if (switching) {
|
||||
note = localModelT('You can close this window. The switch continues while Codeman is running.');
|
||||
} else {
|
||||
note = downloading
|
||||
? localModelT('You can close this window. The download continues while Codeman is running.')
|
||||
: localModelT('You can close this window. The install continues while Codeman is running.');
|
||||
}
|
||||
parts.push(localModelEl('p', 'lm-aside', note));
|
||||
// A paused job that has a checked model beside it: the way back to that model.
|
||||
const library = paused ? this._localModelLibraryEl(install, { onlyWithActions: true }) : null;
|
||||
if (library) parts.push(library);
|
||||
const notice = this._localModelNoticeEl();
|
||||
if (notice) parts.push(notice);
|
||||
|
||||
@@ -1548,6 +1878,9 @@ Object.assign(CodemanApp.prototype, {
|
||||
parts.push(this._localModelRemoveEl());
|
||||
return parts;
|
||||
}
|
||||
// A checked model beside the one that stopped: the way back to it.
|
||||
const library = this._localModelLibraryEl(install, { onlyWithActions: true });
|
||||
if (library) parts.push(library);
|
||||
const notice = this._localModelNoticeEl();
|
||||
if (notice) parts.push(notice);
|
||||
|
||||
@@ -1641,13 +1974,17 @@ Object.assign(CodemanApp.prototype, {
|
||||
const tools = this._localModelDeveloperToolsEl();
|
||||
if (tools) parts.push(tools);
|
||||
const notice = this._localModelNoticeEl();
|
||||
if (notice) parts.push(notice);
|
||||
|
||||
const can = (action) => install.actions.includes(action);
|
||||
const next = error ? this._localModelNextButton(install, error) : null;
|
||||
// The screen that ends an install has one button. Stop and Remove are for the window
|
||||
// opened later (or for this one once anything else was said in it).
|
||||
const ending = ui.justInstalled === true && can('start-session') && !error && !notice;
|
||||
// Everything that is on this Mac, what it takes on disk, and the way to another model.
|
||||
// Not on the screen that ends an install: that one says one thing.
|
||||
const library = ending ? null : this._localModelLibraryEl(install);
|
||||
if (library) parts.push(library);
|
||||
if (notice) parts.push(notice);
|
||||
const foot = localModelEl('div', 'lm-foot');
|
||||
const buttons = localModelEl('div', 'lm-buttons');
|
||||
if (!ending && can('remove') && error?.next !== 'remove') {
|
||||
@@ -1703,6 +2040,8 @@ Object.assign(CodemanApp.prototype, {
|
||||
if (hw) parts.push(hw);
|
||||
const reason = localModelMessage(status.recommendation.headline);
|
||||
if (reason) parts.push(localModelEl('p', 'lm-reason', reason));
|
||||
const leftovers = this._localModelStoreEl(localModelInstall(status));
|
||||
if (leftovers) parts.push(leftovers);
|
||||
const notice = this._localModelNoticeEl();
|
||||
if (notice) parts.push(notice);
|
||||
const foot = localModelEl('div', 'lm-foot');
|
||||
|
||||
@@ -385,13 +385,13 @@ Object.assign(CodemanApp.prototype, {
|
||||
},
|
||||
|
||||
/**
|
||||
* One delegated click listener for every row. The breakpoint listener lives
|
||||
* in `_wireMobileOverviewBreakpoint()`, installed at startup.
|
||||
* One delegated click listener for every row, plus a breakpoint listener so
|
||||
* rotating or unfolding while on the home screen swaps to the right surface
|
||||
* instead of stranding a phone layout on a tablet-width viewport.
|
||||
*/
|
||||
_wireMobileOverview(el) {
|
||||
if (this._mobileOverviewWired) return;
|
||||
this._mobileOverviewWired = true;
|
||||
this._wireMobileOverviewBreakpoint();
|
||||
|
||||
el.addEventListener('click', (event) => {
|
||||
const target = event.target && event.target.closest && event.target.closest('[data-mo-action]');
|
||||
@@ -426,34 +426,18 @@ Object.assign(CodemanApp.prototype, {
|
||||
this.showWebviewModal();
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Swap the home surface when the width crosses the phone breakpoint (rotate,
|
||||
* fold, unfold), so a phone layout is never stranded on a tablet-width
|
||||
* viewport and the welcome screen never on a phone-width one.
|
||||
*
|
||||
* Installed ONCE at startup (`init()` in app.js), not on the first show: a
|
||||
* page that loads wide never shows the phone overview, so a listener wired
|
||||
* from there was missing on exactly the fold that needed it (#702). Idempotent,
|
||||
* so the call from `_wireMobileOverview()` adds nothing after startup.
|
||||
*/
|
||||
_wireMobileOverviewBreakpoint() {
|
||||
if (this._mobileOverviewBreakpointWired || !window.matchMedia) return;
|
||||
this._mobileOverviewBreakpointWired = true;
|
||||
const mq = window.matchMedia(MOBILE_OVERVIEW_PHONE_QUERY);
|
||||
const onChange = () => {
|
||||
// Only relevant while a home surface is up; entering a session re-decides
|
||||
// through hideWelcome()/showWelcome() anyway, and before the first
|
||||
// showWelcome() there is nothing on screen to swap.
|
||||
if (this.activeSessionId) return;
|
||||
const overlay = document.getElementById('welcomeOverlay');
|
||||
const welcomeUp = !!overlay && overlay.classList.contains('visible');
|
||||
if (!welcomeUp && !this.isMobileOverviewVisible()) return;
|
||||
if (typeof this.showWelcome === 'function') this.showWelcome();
|
||||
};
|
||||
if (mq.addEventListener) mq.addEventListener('change', onChange);
|
||||
else if (mq.addListener) mq.addListener(onChange);
|
||||
if (window.matchMedia) {
|
||||
const mq = window.matchMedia(MOBILE_OVERVIEW_PHONE_QUERY);
|
||||
const onChange = () => {
|
||||
// Only relevant while a home surface is up; entering a session re-decides
|
||||
// through hideWelcome()/showWelcome() anyway.
|
||||
if (this.activeSessionId) return;
|
||||
if (typeof this.showWelcome === 'function') this.showWelcome();
|
||||
};
|
||||
if (mq.addEventListener) mq.addEventListener('change', onChange);
|
||||
else if (mq.addListener) mq.addListener(onChange);
|
||||
}
|
||||
},
|
||||
|
||||
_toggleMobileOverviewRunMenu() {
|
||||
|
||||
@@ -1887,6 +1887,11 @@ html.mobile-init .file-browser-panel {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
/* A screen with the list of models scrolls from its top. */
|
||||
.local-model-window > .lm-body[data-long='true'] {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
/* Ralph wizard responsive */
|
||||
.modal-wizard {
|
||||
display: flex;
|
||||
@@ -4339,6 +4344,61 @@ html[data-session-list="sidebar"] .session-sidebar .session-tab .tab-close {
|
||||
grid-column: 3;
|
||||
}
|
||||
|
||||
/* The models on this Mac: the name and its size, then the state and what can be done. */
|
||||
.lm-lib-list {
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
column-gap: 0.7rem;
|
||||
}
|
||||
|
||||
.lm-lib-caption-label {
|
||||
grid-column: 1;
|
||||
}
|
||||
|
||||
.lm-lib-caption-size {
|
||||
grid-column: 2;
|
||||
}
|
||||
|
||||
.lm-lib-row {
|
||||
row-gap: 0.15rem;
|
||||
padding-top: 0.45rem;
|
||||
padding-bottom: 0.45rem;
|
||||
}
|
||||
|
||||
.lm-lib-name {
|
||||
grid-row: 1;
|
||||
grid-column: 1;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.lm-lib-size {
|
||||
grid-row: 1;
|
||||
grid-column: 2;
|
||||
}
|
||||
|
||||
.lm-lib-tag {
|
||||
grid-row: 2;
|
||||
grid-column: 1;
|
||||
}
|
||||
|
||||
.lm-lib-actions {
|
||||
grid-row: 2;
|
||||
grid-column: 2;
|
||||
}
|
||||
|
||||
.lm-lib-ask {
|
||||
margin-inline: 0.7rem;
|
||||
}
|
||||
|
||||
.lm-lib-foot {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* A row's buttons keep their own size: they sit in the row, not under the thumb. */
|
||||
.local-model-window .wt-btn.lm-btn.lm-btn-row {
|
||||
min-height: 36px;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.lm-foot {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
|
||||
@@ -3777,7 +3777,6 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
|
||||
const html = [];
|
||||
const canDelete = this._fileBrowserCanDelete(owner);
|
||||
|
||||
const renderNode = (node, depth) => {
|
||||
const isDir = node.type === 'directory';
|
||||
@@ -3800,13 +3799,6 @@ Object.assign(CodemanApp.prototype, {
|
||||
const downloadBtn = !isDir
|
||||
? `<a class="file-tree-download" href="${escapeHtml(CodemanBase.url(`/api/sessions/${encodeURIComponent(owner)}/file-raw?path=${encodeURIComponent(node.path)}&download=true`))}" title="Download" onclick="event.stopPropagation()">⬇</a>`
|
||||
: '';
|
||||
// No inline handler: the path is a workspace file name, which an agent or a cloned repo
|
||||
// chose, and `encodeURIComponent` leaves `'` alone, so a quoted string in an onclick ran
|
||||
// whatever a file name like `x'-alert(1)-'.txt` held (#529's review). The click is bound
|
||||
// below from the row's already-escaped `data-path`.
|
||||
const deleteBtn = !isDir && canDelete
|
||||
? `<button type="button" class="file-tree-delete" title="Delete" aria-label="Delete ${escapeHtml(node.name)}">×</button>`
|
||||
: '';
|
||||
|
||||
html.push(`
|
||||
<div class="file-tree-item" data-path="${escapeHtml(node.path)}" data-type="${escapeHtml(node.type)}" data-depth="${depth}">
|
||||
@@ -3815,7 +3807,6 @@ Object.assign(CodemanApp.prototype, {
|
||||
<span class="${nameClass}">${escapeHtml(node.name)}</span>
|
||||
${sizeStr}
|
||||
${downloadBtn}
|
||||
${deleteBtn}
|
||||
</div>
|
||||
`);
|
||||
|
||||
@@ -3851,21 +3842,9 @@ Object.assign(CodemanApp.prototype, {
|
||||
this.openFilePreview(path, owner);
|
||||
}
|
||||
});
|
||||
item.querySelector('.file-tree-delete')?.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
this.deleteFileFromBrowser(owner, item.dataset.path);
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
// The delete button is offered only where the route can act: never for a remote (SSH) session,
|
||||
// whose files live on another host, nor for an adopted one, whose folder is not a workspace of
|
||||
// ours (`session.remote` is undefined on an adopted session, so both are tested).
|
||||
_fileBrowserCanDelete(sessionId) {
|
||||
const session = this.sessions?.get?.(sessionId);
|
||||
return !session?.remote && !session?.adopt;
|
||||
},
|
||||
|
||||
toggleFileBrowserFolder(path) {
|
||||
if (this.fileBrowserExpandedDirs.has(path)) {
|
||||
this.fileBrowserExpandedDirs.delete(path);
|
||||
@@ -3991,7 +3970,6 @@ Object.assign(CodemanApp.prototype, {
|
||||
treeEl.innerHTML = `<div class="file-browser-empty">${escapeHtml('No matches')}</div>`;
|
||||
} else {
|
||||
const ownerPath = encodeURIComponent(ownerSessionId);
|
||||
const canDelete = this._fileBrowserCanDelete(ownerSessionId);
|
||||
treeEl.innerHTML = matches
|
||||
.map(match => {
|
||||
const isDir = match.type === 'directory';
|
||||
@@ -4003,10 +3981,6 @@ Object.assign(CodemanApp.prototype, {
|
||||
const downloadBtn = !isDir
|
||||
? `<a class="file-tree-download" href="${escapeHtml(CodemanBase.url(`/api/sessions/${ownerPath}/file-raw?path=${encodeURIComponent(match.path)}&download=true`))}" title="Download" onclick="event.stopPropagation()">⬇</a>`
|
||||
: '';
|
||||
// Bound below from `data-path`, never an inline handler (see renderFileBrowserTree).
|
||||
const deleteBtn = !isDir && canDelete
|
||||
? `<button type="button" class="file-tree-delete" title="Delete" aria-label="Delete ${escapeHtml(match.name)}">×</button>`
|
||||
: '';
|
||||
return `
|
||||
<div class="file-tree-item" data-path="${escapeHtml(match.path)}" data-type="${escapeHtml(match.type)}" data-owner="${escapeHtml(ownerSessionId)}">
|
||||
<span class="file-tree-expand"></span>
|
||||
@@ -4014,7 +3988,6 @@ Object.assign(CodemanApp.prototype, {
|
||||
<span class="${nameClass}">${escapeHtml(match.name)}</span>
|
||||
${sizeStr}
|
||||
${downloadBtn}
|
||||
${deleteBtn}
|
||||
</div>
|
||||
`;
|
||||
})
|
||||
@@ -4030,10 +4003,6 @@ Object.assign(CodemanApp.prototype, {
|
||||
this.openFilePreview(path, ownerSessionId);
|
||||
}
|
||||
});
|
||||
item.querySelector('.file-tree-delete')?.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
this.deleteFileFromBrowser(ownerSessionId, item.dataset.path);
|
||||
});
|
||||
});
|
||||
|
||||
const statusEl = this.$('fileBrowserStatus');
|
||||
@@ -4043,33 +4012,6 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
},
|
||||
|
||||
// The Files panel's delete button (DELETE .../files?path=): asks first, in the same native
|
||||
// dialog the panel's other destructive actions use, then reloads the listing. With a search in
|
||||
// the box the reload re-runs it, so the deleted row leaves the results too; a plain reload
|
||||
// would refresh the tree behind them and leave the row on screen.
|
||||
async deleteFileFromBrowser(sessionId, filePath) {
|
||||
if (!sessionId || !filePath) return;
|
||||
if (!confirm(`Delete the file ${filePath}? This cannot be undone.`)) return;
|
||||
let result = null;
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/sessions/${encodeURIComponent(sessionId)}/files?path=${encodeURIComponent(filePath)}`,
|
||||
{ method: 'DELETE' },
|
||||
);
|
||||
result = await response.json().catch(() => null);
|
||||
if (!response.ok || result?.success !== true) {
|
||||
throw new Error(result?.error || `HTTP ${response.status}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('File browser delete failed:', error);
|
||||
this.showToast(`Delete failed: ${error?.message || 'network error'}`, 'error');
|
||||
return;
|
||||
}
|
||||
this.showToast(`File deleted: ${filePath}`, 'success');
|
||||
const state = this._ensureFileBrowserState();
|
||||
if (state.ownerSessionId === sessionId) await this._reloadFileBrowserKeepingQuery();
|
||||
},
|
||||
|
||||
_findFileBrowserDirectory(nodes, targetPath, ancestors = []) {
|
||||
if (!Array.isArray(nodes)) return null;
|
||||
for (const node of nodes) {
|
||||
@@ -6163,7 +6105,6 @@ Object.assign(CodemanApp.prototype, {
|
||||
CodemanBase.url(`/api/sessions/${sessionId}/tail-file?path=${encodeURIComponent(filePath)}&lines=50`)
|
||||
);
|
||||
|
||||
let connected = false;
|
||||
eventSource.onmessage = (e) => {
|
||||
const data = JSON.parse(e.data);
|
||||
const body = document.getElementById(`log-viewer-body-${windowId}`);
|
||||
@@ -6171,7 +6112,6 @@ Object.assign(CodemanApp.prototype, {
|
||||
|
||||
switch (data.type) {
|
||||
case 'connected':
|
||||
connected = true;
|
||||
body.innerHTML = '';
|
||||
break;
|
||||
case 'data':
|
||||
@@ -6191,7 +6131,6 @@ Object.assign(CodemanApp.prototype, {
|
||||
this.updateLogViewerStatus(windowId, 'disconnected', 'ended');
|
||||
break;
|
||||
case 'error':
|
||||
if (!connected) eventSource.close();
|
||||
body.innerHTML += `<div class="log-error">${escapeHtml(data.error)}</div>`;
|
||||
this.updateLogViewerStatus(windowId, 'error', 'error');
|
||||
break;
|
||||
|
||||
@@ -613,9 +613,6 @@ 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
|
||||
@@ -643,29 +640,15 @@ 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;
|
||||
// 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;
|
||||
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);
|
||||
}
|
||||
},
|
||||
|
||||
async quickStart() {
|
||||
@@ -3687,111 +3670,6 @@ 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,7 +560,6 @@ 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()).
|
||||
@@ -811,8 +810,6 @@ Object.assign(CodemanApp.prototype, {
|
||||
document.getElementById('voiceKeyterms').value = voiceCfg.keyterms || DEFAULT_VOICE_KEYTERMS;
|
||||
document.getElementById('voiceInsertMode').value = voiceCfg.insertMode || 'direct';
|
||||
document.getElementById('voiceProvider').value = voiceCfg.provider || 'auto';
|
||||
document.getElementById('voiceSilenceTimeout').value = VoiceInput.SILENCE_TIMEOUT_SECONDS.includes(voiceCfg.silenceTimeout)
|
||||
? String(voiceCfg.silenceTimeout) : 'default';
|
||||
document.getElementById('appSettingsClaudeVoice').checked = settings.claudeVoiceEnabled ?? false;
|
||||
// Reset key visibility to hidden
|
||||
const keyInput = document.getElementById('voiceDeepgramKey');
|
||||
@@ -3142,7 +3139,6 @@ 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),
|
||||
@@ -3301,8 +3297,6 @@ Object.assign(CodemanApp.prototype, {
|
||||
language: document.getElementById('voiceLanguage').value,
|
||||
keyterms: document.getElementById('voiceKeyterms').value.trim(),
|
||||
insertMode: document.getElementById('voiceInsertMode').value,
|
||||
silenceTimeout: document.getElementById('voiceSilenceTimeout').value === 'default'
|
||||
? undefined : Number(document.getElementById('voiceSilenceTimeout').value),
|
||||
};
|
||||
VoiceInput._saveDeepgramConfig(voiceSettings);
|
||||
|
||||
@@ -5249,7 +5243,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, caseSettings: _sharedCases, ...appSettings } = settings;
|
||||
const { notificationPreferences, voiceSettings, respawnPresets, runMode, ...appSettings } = settings;
|
||||
// Display settings are device-specific (mobile vs desktop) and must not be synced
|
||||
// from the server: see _displaySettingKeySet().
|
||||
const displayKeys = this._displaySettingKeySet();
|
||||
@@ -5286,7 +5280,6 @@ 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) {
|
||||
|
||||
+210
-72
@@ -12386,32 +12386,6 @@ html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="cat
|
||||
.file-tree-download:hover {
|
||||
color: #4fc3f7;
|
||||
}
|
||||
.file-tree-delete {
|
||||
display: none;
|
||||
margin-left: 2px;
|
||||
padding: 0 4px;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: #888;
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.file-tree-item:hover .file-tree-delete {
|
||||
display: inline;
|
||||
}
|
||||
.file-tree-delete:hover {
|
||||
color: #ef5350;
|
||||
}
|
||||
/* A touch screen has no hover, so the row's buttons stay visible there: otherwise the first tap
|
||||
on a row opens the preview and the buttons never show. */
|
||||
@media (hover: none) {
|
||||
.file-tree-download,
|
||||
.file-tree-delete {
|
||||
display: inline;
|
||||
}
|
||||
}
|
||||
|
||||
/* File tree indent levels */
|
||||
.file-tree-item[data-depth="0"] { padding-left: 0.5rem; }
|
||||
@@ -16244,29 +16218,6 @@ body.touch-device.cjk-input-visible .main {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.path-preview-zoom {
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.path-preview-zoom[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.path-preview-zoom button {
|
||||
min-width: 32px;
|
||||
height: 34px;
|
||||
padding: 0 6px;
|
||||
color: var(--text);
|
||||
background: var(--bg-input);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 7px;
|
||||
font-size: 0.75rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.path-preview-close {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
@@ -16285,12 +16236,6 @@ body.touch-device.cjk-input-visible .main {
|
||||
background: #111;
|
||||
}
|
||||
|
||||
.path-preview-body.path-preview-image-viewport {
|
||||
overflow: hidden;
|
||||
touch-action: none;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
.path-preview-loading,
|
||||
.path-preview-error {
|
||||
padding: 24px;
|
||||
@@ -16305,23 +16250,10 @@ body.touch-device.cjk-input-visible .main {
|
||||
|
||||
.path-preview-image {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
/* scale-down, not contain: a small image keeps its own size instead of being blown up to the
|
||||
viewport (the zoom controls are there to enlarge it). */
|
||||
object-fit: scale-down;
|
||||
touch-action: none;
|
||||
user-select: none;
|
||||
-webkit-user-drag: none;
|
||||
cursor: zoom-in;
|
||||
}
|
||||
|
||||
.path-preview-image.is-zoomed {
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.path-preview-image.is-zoomed:active {
|
||||
cursor: grabbing;
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
margin: auto;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.path-preview-frame {
|
||||
@@ -22535,6 +22467,13 @@ html[data-skin='rose-pine-dawn'] {
|
||||
padding-bottom: 2.2rem;
|
||||
}
|
||||
|
||||
/* A screen that carries the list of models is no short screen: it starts at the
|
||||
top and scrolls, or its first lines would be cut off above the frame. */
|
||||
.local-model-window > .lm-body[data-long='true'] {
|
||||
justify-content: flex-start;
|
||||
padding-bottom: 1.2rem;
|
||||
}
|
||||
|
||||
/* The foot of the frame: the buttons of the screen (or the Remove question),
|
||||
outside the part that scrolls. */
|
||||
.local-model-window > .lm-foot-slot {
|
||||
@@ -22876,7 +22815,9 @@ html[data-skin='rose-pine-dawn'] {
|
||||
|
||||
.lm-model-tag {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-start;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
/* A tag says one thing about a row, in the colour of what it says. */
|
||||
@@ -22912,6 +22853,29 @@ html[data-skin='rose-pine-dawn'] {
|
||||
color: var(--lm-orange);
|
||||
}
|
||||
|
||||
/* The states of a model that is on this Mac: in use takes the feature's own
|
||||
colour, ready is good, a changed or missing one is a warning. A model that
|
||||
was never checked keeps the quiet tag. */
|
||||
.lm-tag-inuse {
|
||||
background: color-mix(in srgb, var(--lm-brand) 20%, transparent);
|
||||
color: var(--lm-brand);
|
||||
}
|
||||
|
||||
.lm-tag-ready {
|
||||
background: color-mix(in srgb, var(--lm-good) 15%, transparent);
|
||||
color: var(--lm-good);
|
||||
}
|
||||
|
||||
.lm-tag-changed {
|
||||
background: color-mix(in srgb, var(--lm-amber) 15%, transparent);
|
||||
color: var(--lm-amber);
|
||||
}
|
||||
|
||||
.lm-tag-missing {
|
||||
background: color-mix(in srgb, var(--lm-orange) 15%, transparent);
|
||||
color: var(--lm-orange);
|
||||
}
|
||||
|
||||
.lm-model-speed,
|
||||
.lm-model-size {
|
||||
text-align: end;
|
||||
@@ -23286,6 +23250,176 @@ html[data-skin='rose-pine-dawn'] {
|
||||
accent-color: var(--lm-brand);
|
||||
}
|
||||
|
||||
/* ── the models on this Mac ───────────────────────────────────────── */
|
||||
/* Name with its context, state, size on disk, and what can be done with the
|
||||
row. One grid like the chooser's, so the sizes line up down the list. The
|
||||
model in use wears the chooser's selected look: it is the one that runs. */
|
||||
.lm-library {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.55rem;
|
||||
}
|
||||
|
||||
.lm-lib-list {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, max-content) minmax(0, 1fr) auto auto;
|
||||
column-gap: 0.75rem;
|
||||
row-gap: 0.1rem;
|
||||
margin-inline: calc(-0.7rem - 2px) -0.7rem;
|
||||
}
|
||||
|
||||
.lm-lib-caption,
|
||||
.lm-lib-row {
|
||||
display: grid;
|
||||
grid-column: 1 / -1;
|
||||
grid-template-columns: subgrid;
|
||||
align-items: center;
|
||||
padding: 0 0.7rem;
|
||||
border-inline-start: 2px solid transparent;
|
||||
}
|
||||
|
||||
.lm-lib-caption {
|
||||
padding-bottom: 0.3rem;
|
||||
font-size: 0.7rem;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.lm-lib-caption-label {
|
||||
grid-column: 1 / span 2;
|
||||
}
|
||||
|
||||
.lm-lib-caption-size {
|
||||
grid-column: 3;
|
||||
text-align: end;
|
||||
}
|
||||
|
||||
.lm-lib-row {
|
||||
min-height: 2.9rem;
|
||||
border-radius: 9px;
|
||||
}
|
||||
|
||||
.lm-lib-row-on {
|
||||
border-inline-start-color: var(--lm-brand);
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
color-mix(in srgb, var(--lm-brand) 17%, transparent),
|
||||
color-mix(in srgb, var(--lm-brand) 4%, transparent)
|
||||
);
|
||||
}
|
||||
|
||||
.lm-lib-name {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
padding: 0.35rem 0;
|
||||
}
|
||||
|
||||
.lm-lib-label {
|
||||
font-size: 0.86rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.35;
|
||||
color: var(--text);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.lm-lib-meta {
|
||||
font-size: 0.7rem;
|
||||
line-height: 1.4;
|
||||
color: var(--text-dim);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.lm-lib-tag {
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.lm-lib-size {
|
||||
text-align: end;
|
||||
white-space: nowrap;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 560;
|
||||
color: var(--text);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.lm-lib-actions {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
justify-content: flex-end;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
/* While a row's Delete question is open its buttons keep their room, unseen:
|
||||
the sizes above and below must not jump sideways. */
|
||||
.lm-lib-actions-away {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
/* The buttons of a row are a size smaller than the buttons of the screen. */
|
||||
.local-model-window .wt-btn.lm-btn.lm-btn-row {
|
||||
padding: 0.28rem 0.65rem;
|
||||
font-size: 0.74rem;
|
||||
}
|
||||
|
||||
/* The Delete question of one row, in place under it: the row is what it is about. */
|
||||
.lm-lib-ask {
|
||||
display: flex;
|
||||
grid-column: 1 / -1;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem 1rem;
|
||||
margin: 0.1rem 0.7rem 0.4rem;
|
||||
padding: 0.6rem 0.75rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 9px;
|
||||
}
|
||||
|
||||
.lm-lib-ask-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.1rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Under the list: what all downloads take on disk, and the way to another model. */
|
||||
.lm-lib-foot {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem 1rem;
|
||||
}
|
||||
|
||||
.lm-lib-notes {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.15rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Nothing installed, and an earlier Remove kept its downloads. */
|
||||
.lm-leftovers .lm-lib-ask {
|
||||
margin-inline: 0;
|
||||
}
|
||||
|
||||
/* A browser without subgrid: fixed tracks keep the columns in line. */
|
||||
@supports not (grid-template-columns: subgrid) {
|
||||
.lm-lib-list {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.lm-lib-caption,
|
||||
.lm-lib-row {
|
||||
grid-template-columns: minmax(0, 12rem) minmax(0, 1fr) 4.5rem minmax(0, auto);
|
||||
column-gap: 0.75rem;
|
||||
}
|
||||
|
||||
.lm-lib-row {
|
||||
margin-top: 0.1rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── the foot: one promise on the left, the action on the right ──── */
|
||||
.lm-foot {
|
||||
display: flex;
|
||||
@@ -23361,6 +23495,10 @@ html[data-skin='rose-pine-dawn'] {
|
||||
min-height: 2.9rem;
|
||||
}
|
||||
|
||||
.local-model-window .wt-btn.lm-btn.lm-btn-row {
|
||||
min-height: 36px;
|
||||
}
|
||||
|
||||
.lm-select {
|
||||
min-height: 36px;
|
||||
}
|
||||
|
||||
+137
-232
@@ -14,11 +14,11 @@
|
||||
* auto-detects MIME type (opus/webm/mp4), and supports custom key terms for dev vocabulary.
|
||||
*
|
||||
* - VoiceInput — High-level voice input controller. Toggle mode: tap mic to start, tap
|
||||
* again to stop. Auto-stops after configurable silence. Shows floating preview overlay with recording
|
||||
* again to stop. Auto-stops after 3s silence. Shows floating preview overlay with recording
|
||||
* indicator, level meter (AnalyserNode), and elapsed timer. Two insert modes: "direct"
|
||||
* (inject into local echo overlay or PTY) and "compose" (editable textarea overlay).
|
||||
* Includes a temporary green Send button that replaces the settings gear icon after voice input.
|
||||
* Web Speech API retries empty runs twice with backoff and preserves late results after Stop.
|
||||
* Web Speech API has auto-retry (up to 2x) for premature onend and iOS Safari stability check.
|
||||
*
|
||||
* @globals {object} ClaudeVoiceProvider
|
||||
* @globals {object} DeepgramProvider
|
||||
@@ -216,12 +216,9 @@ const DeepgramProvider = {
|
||||
|
||||
_resetSilenceTimeout() {
|
||||
clearTimeout(this._silenceTimeout);
|
||||
this._silenceTimeout = null;
|
||||
const delay = VoiceInput.getSilenceTimeoutMs(3000);
|
||||
if (delay === 0) return;
|
||||
this._silenceTimeout = setTimeout(() => {
|
||||
this.stop();
|
||||
}, delay);
|
||||
}, 3000);
|
||||
},
|
||||
|
||||
stop() {
|
||||
@@ -272,8 +269,8 @@ const DeepgramProvider = {
|
||||
* DeepgramProvider's recorder. The AudioContext is constructed at 16000 Hz so the
|
||||
* browser does the resampling.
|
||||
*
|
||||
* Transcript frames cover the current utterance. Replace interims and append
|
||||
* each endpoint final once; an endpoint alone does not end the recording.
|
||||
* ⚠️ Transcript frames carry the WHOLE running transcript, not deltas. Callers
|
||||
* must replace, never concatenate.
|
||||
*/
|
||||
const ClaudeVoiceProvider = {
|
||||
_ws: null,
|
||||
@@ -287,8 +284,6 @@ const ClaudeVoiceProvider = {
|
||||
_onError: null,
|
||||
_onEnd: null,
|
||||
_finalized: false,
|
||||
_finalizeTimeout: null,
|
||||
_generation: 0,
|
||||
|
||||
/** How long without any transcript before the recording gives up on its own. */
|
||||
SILENCE_MS: 6000,
|
||||
@@ -298,8 +293,6 @@ const ClaudeVoiceProvider = {
|
||||
* @param {object} opts - { language, keyterms[], onResult(text, isFinal), onError(msg), onEnd(), onStream(stream) }
|
||||
*/
|
||||
async start(opts) {
|
||||
this._cleanup();
|
||||
const generation = this._generation;
|
||||
this._onResult = opts.onResult;
|
||||
this._onError = opts.onError;
|
||||
this._onEnd = opts.onEnd;
|
||||
@@ -311,17 +304,10 @@ const ClaudeVoiceProvider = {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({
|
||||
this._stream = await navigator.mediaDevices.getUserMedia({
|
||||
audio: { noiseSuppression: true, echoCancellation: true, autoGainControl: true }
|
||||
});
|
||||
// Permission prompts can resolve after Stop/reconnect or a newer recording.
|
||||
if (generation !== this._generation || this._finalized) {
|
||||
stream.getTracks().forEach(t => t.stop());
|
||||
return;
|
||||
}
|
||||
this._stream = stream;
|
||||
} catch (err) {
|
||||
if (generation !== this._generation || this._finalized) return;
|
||||
const msg = err.name === 'NotAllowedError'
|
||||
? 'Microphone access denied. Check browser settings.'
|
||||
: 'Microphone error: ' + err.message;
|
||||
@@ -345,12 +331,10 @@ const ClaudeVoiceProvider = {
|
||||
this._ws.binaryType = 'arraybuffer';
|
||||
|
||||
this._ws.onopen = () => {
|
||||
if (generation !== this._generation || this._finalized) return;
|
||||
// Capture starts only once the socket is up: PCM buffered before that would
|
||||
// be the oldest audio, and dropping it keeps the transcript aligned with what
|
||||
// the user hears themselves saying.
|
||||
this._startCapture(generation).catch((err) => {
|
||||
if (generation !== this._generation || this._finalized) return;
|
||||
this._startCapture().catch((err) => {
|
||||
this._onError?.('Microphone capture failed: ' + err.message);
|
||||
this.stop();
|
||||
});
|
||||
@@ -358,7 +342,6 @@ const ClaudeVoiceProvider = {
|
||||
};
|
||||
|
||||
this._ws.onmessage = (event) => {
|
||||
if (generation !== this._generation) return;
|
||||
let msg;
|
||||
try {
|
||||
msg = JSON.parse(event.data);
|
||||
@@ -378,7 +361,6 @@ const ClaudeVoiceProvider = {
|
||||
};
|
||||
|
||||
this._ws.onclose = (event) => {
|
||||
if (generation !== this._generation) return;
|
||||
if (event.code === 4004) {
|
||||
this._onError?.(this._unavailableMessage(event.reason));
|
||||
} else if (event.code === 4008) {
|
||||
@@ -403,19 +385,16 @@ const ClaudeVoiceProvider = {
|
||||
},
|
||||
|
||||
/** Wire mic -> 16 kHz PCM16 frames -> WebSocket. */
|
||||
async _startCapture(generation = this._generation) {
|
||||
if (generation !== this._generation || this._finalized) return;
|
||||
async _startCapture() {
|
||||
const Ctx = window.AudioContext || window.webkitAudioContext;
|
||||
// Ask for 16 kHz directly so the browser resamples; Safari may hand back its
|
||||
// own rate, which _pcmFromFloat32 then downsamples to match.
|
||||
this._audioContext = new Ctx({ sampleRate: 16000 });
|
||||
if (this._audioContext.state === 'suspended') await this._audioContext.resume();
|
||||
if (generation !== this._generation || this._finalized) return;
|
||||
this._sourceNode = this._audioContext.createMediaStreamSource(this._stream);
|
||||
|
||||
if (this._audioContext.audioWorklet) {
|
||||
await this._audioContext.audioWorklet.addModule(this._workletUrl());
|
||||
if (generation !== this._generation || this._finalized) return;
|
||||
this._workletNode = new AudioWorkletNode(this._audioContext, 'pcm-frame-processor');
|
||||
this._workletNode.port.onmessage = (event) => this._sendAudio(event.data);
|
||||
this._sourceNode.connect(this._workletNode);
|
||||
@@ -476,10 +455,7 @@ const ClaudeVoiceProvider = {
|
||||
|
||||
_resetSilenceTimeout() {
|
||||
clearTimeout(this._silenceTimeout);
|
||||
this._silenceTimeout = null;
|
||||
const delay = VoiceInput.getSilenceTimeoutMs(this.SILENCE_MS);
|
||||
if (delay === 0) return;
|
||||
this._silenceTimeout = setTimeout(() => this.stop(), delay);
|
||||
this._silenceTimeout = setTimeout(() => this.stop(), this.SILENCE_MS);
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -493,13 +469,6 @@ const ClaudeVoiceProvider = {
|
||||
if (this._finalized) return;
|
||||
this._finalized = true;
|
||||
this._stopCapture();
|
||||
// Bound both manual and silence-triggered finalization on a dead connection.
|
||||
// Repeated stop requests return above, so they cannot extend this deadline.
|
||||
this._finalizeTimeout = setTimeout(() => {
|
||||
const onEnd = this._onEnd;
|
||||
this._cleanup();
|
||||
onEnd?.();
|
||||
}, 5000);
|
||||
if (this._ws?.readyState === WebSocket.OPEN) {
|
||||
try {
|
||||
this._ws.send(JSON.stringify({ t: 'finalize' }));
|
||||
@@ -541,19 +510,15 @@ const ClaudeVoiceProvider = {
|
||||
|
||||
/** Hard stop: drop the socket without waiting for a final transcript. */
|
||||
_cleanup() {
|
||||
this._generation++;
|
||||
this._finalized = true;
|
||||
clearTimeout(this._finalizeTimeout);
|
||||
this._finalizeTimeout = null;
|
||||
clearTimeout(this._silenceTimeout);
|
||||
this._silenceTimeout = null;
|
||||
this._stopCapture();
|
||||
if (this._ws) {
|
||||
this._ws.onopen = null;
|
||||
this._ws.onclose = null;
|
||||
this._ws.onmessage = null;
|
||||
this._ws.onerror = null;
|
||||
if (this._ws.readyState === WebSocket.OPEN || this._ws.readyState === WebSocket.CONNECTING) {
|
||||
if (this._ws.readyState === WebSocket.OPEN) {
|
||||
try { this._ws.close(1000); } catch (_e) { /* ignore */ }
|
||||
}
|
||||
this._ws = null;
|
||||
@@ -572,27 +537,18 @@ const ClaudeVoiceProvider = {
|
||||
* Inserts final text into the active session (user presses Enter to submit).
|
||||
*/
|
||||
const VoiceInput = {
|
||||
SILENCE_TIMEOUT_SECONDS: Object.freeze([0, 3, 6, 15, 30]),
|
||||
WEB_SPEECH_INSTANT_END_MS: 1000, // an empty run shorter than this counts toward the retry cap
|
||||
recognition: null,
|
||||
isRecording: false,
|
||||
supported: false,
|
||||
silenceTimeout: null,
|
||||
previewEl: null,
|
||||
_lastTranscript: '',
|
||||
_stabilityTimer: null,
|
||||
_accumulatedFinal: '',
|
||||
_pendingInterim: '',
|
||||
_webSpeechCommitted: '', // Text from completed native recognition runs.
|
||||
_webSpeechSingleUtterance: false,
|
||||
_webSpeechSoundActive: false,
|
||||
_webSpeechActivityUntil: 0,
|
||||
_webSpeechStopping: false,
|
||||
_webSpeechStopTimer: null,
|
||||
_webSpeechRestartTimer: null,
|
||||
_activeProvider: null, // 'claude' | 'deepgram' | 'webspeech' | null
|
||||
_activeProvider: null, // 'deepgram' | 'webspeech' | null
|
||||
_recordingStartedAt: 0, // timestamp when recording started
|
||||
_retryCount: 0, // consecutive Web Speech runs that ended empty right after starting
|
||||
_hasReceivedResult: false, // whether this native recognition run received speech
|
||||
_webSpeechRunStartedAt: 0, // when the current native recognition run was started
|
||||
_retryCount: 0, // auto-retry counter for premature Web Speech API ends
|
||||
_hasReceivedResult: false, // whether any speech result came in this session
|
||||
_durationInterval: null, // timer for updating elapsed time display
|
||||
_analyser: null, // AudioContext analyser for level meter
|
||||
_analyserSource: null, // MediaStreamSource for level meter
|
||||
@@ -643,12 +599,6 @@ const VoiceInput = {
|
||||
}
|
||||
},
|
||||
|
||||
/** Seconds in settings; zero means stop only when the user asks. */
|
||||
getSilenceTimeoutMs(fallback) {
|
||||
const seconds = this._getDeepgramConfig().silenceTimeout;
|
||||
return this.SILENCE_TIMEOUT_SECONDS.includes(seconds) ? seconds * 1000 : fallback;
|
||||
},
|
||||
|
||||
_saveDeepgramConfig(config) {
|
||||
localStorage.setItem('codeman-voice-settings', JSON.stringify(config));
|
||||
},
|
||||
@@ -698,18 +648,7 @@ const VoiceInput = {
|
||||
if (!this.supported) return;
|
||||
|
||||
this.recognition = new SR();
|
||||
// Android's continuous bridge promotes growing partials to separate finals.
|
||||
// Single-utterance mode keeps refinements provisional; onend resumes the mic
|
||||
// while our own silence deadline controls the overall dictation session.
|
||||
// Android tablets in desktop-site mode may identify as touch-capable Linux.
|
||||
// ChromeOS also reports Linux but says CrOS, so it keeps continuous mode.
|
||||
// A Linux laptop with a touchscreen still matches; single-utterance works
|
||||
// there too, with one native restart per phrase.
|
||||
const ua = navigator.userAgent || '';
|
||||
this._webSpeechSingleUtterance = navigator.userAgentData?.platform === 'Android'
|
||||
|| /Android/i.test(ua)
|
||||
|| (navigator.maxTouchPoints > 0 && /Linux/i.test(navigator.platform || '') && !/CrOS/.test(ua));
|
||||
this.recognition.continuous = !this._webSpeechSingleUtterance;
|
||||
this.recognition.continuous = true;
|
||||
this.recognition.interimResults = true;
|
||||
this.recognition.lang = 'en-US';
|
||||
this.recognition.maxAlternatives = 1;
|
||||
@@ -717,8 +656,6 @@ const VoiceInput = {
|
||||
this.recognition.onresult = (e) => this._onWebSpeechResult(e);
|
||||
this.recognition.onerror = (e) => this._onWebSpeechError(e);
|
||||
this.recognition.onend = () => this._onWebSpeechEnd();
|
||||
this.recognition.onsoundstart = this.recognition.onspeechstart = () => this._onWebSpeechSound(true);
|
||||
this.recognition.onsoundend = this.recognition.onspeechend = this.recognition.onaudioend = () => this._onWebSpeechSound(false);
|
||||
},
|
||||
|
||||
toggle() {
|
||||
@@ -769,7 +706,7 @@ const VoiceInput = {
|
||||
this.isRecording = true;
|
||||
this._activeProvider = 'claude';
|
||||
this._accumulatedFinal = '';
|
||||
this._pendingInterim = '';
|
||||
this._lastTranscript = '';
|
||||
this._hasReceivedResult = false;
|
||||
this._recordingStartedAt = Date.now();
|
||||
this._updateButtons('recording');
|
||||
@@ -788,25 +725,25 @@ const VoiceInput = {
|
||||
onResult: (text, isFinal) => {
|
||||
if (!this.isRecording) return;
|
||||
this._hasReceivedResult = true;
|
||||
// Interims replace the current utterance; each endpoint commits it once.
|
||||
// Each frame is the WHOLE running transcript, so replace rather than append.
|
||||
this._accumulatedFinal = text;
|
||||
if (isFinal) {
|
||||
this._accumulatedFinal = [this._accumulatedFinal, text].filter(Boolean).join(' ');
|
||||
this._pendingInterim = '';
|
||||
if (ClaudeVoiceProvider._finalized) this.stop(true);
|
||||
else this._showPreview(this._accumulatedFinal, 'claude');
|
||||
this._hidePreview();
|
||||
this._insertText(text);
|
||||
this.stop();
|
||||
} else {
|
||||
this._pendingInterim = (this._accumulatedFinal ? ' ' : '') + text;
|
||||
this._showPreview(this._accumulatedFinal + this._pendingInterim, 'claude');
|
||||
this._showPreview(text, 'claude');
|
||||
}
|
||||
},
|
||||
onError: (msg) => {
|
||||
const wasRecording = this.isRecording;
|
||||
this.stop(true);
|
||||
this.stop();
|
||||
if (wasRecording) app.showToast(msg, 'error');
|
||||
},
|
||||
onEnd: () => {
|
||||
if (this.isRecording) {
|
||||
this.stop(true);
|
||||
if (this._accumulatedFinal) this._insertText(this._accumulatedFinal);
|
||||
this.stop();
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -819,7 +756,7 @@ const VoiceInput = {
|
||||
this.isRecording = true;
|
||||
this._activeProvider = 'deepgram';
|
||||
this._accumulatedFinal = '';
|
||||
this._pendingInterim = '';
|
||||
this._lastTranscript = '';
|
||||
this._hasReceivedResult = false;
|
||||
this._recordingStartedAt = Date.now();
|
||||
this._updateButtons('recording');
|
||||
@@ -840,12 +777,12 @@ const VoiceInput = {
|
||||
if (!this.isRecording) return;
|
||||
this._hasReceivedResult = true;
|
||||
if (isFinal) {
|
||||
this._accumulatedFinal += (this._accumulatedFinal ? ' ' : '') + text;
|
||||
this._pendingInterim = '';
|
||||
this._showPreview(this._accumulatedFinal, 'deepgram');
|
||||
this._accumulatedFinal += text;
|
||||
this._hidePreview();
|
||||
this._insertText(this._accumulatedFinal);
|
||||
this.stop();
|
||||
} else {
|
||||
this._pendingInterim = (this._accumulatedFinal ? ' ' : '') + text;
|
||||
const display = this._accumulatedFinal + this._pendingInterim;
|
||||
const display = this._accumulatedFinal + text;
|
||||
this._showPreview(display, 'deepgram');
|
||||
}
|
||||
},
|
||||
@@ -856,6 +793,9 @@ const VoiceInput = {
|
||||
},
|
||||
onEnd: () => {
|
||||
if (this.isRecording) {
|
||||
if (this._accumulatedFinal) {
|
||||
this._insertText(this._accumulatedFinal);
|
||||
}
|
||||
this.stop();
|
||||
}
|
||||
}
|
||||
@@ -878,74 +818,44 @@ const VoiceInput = {
|
||||
}
|
||||
this.isRecording = true;
|
||||
this._activeProvider = 'webspeech';
|
||||
this._retryCount = 0;
|
||||
this._webSpeechCommitted = '';
|
||||
this._webSpeechSoundActive = false;
|
||||
this._webSpeechActivityUntil = 0;
|
||||
this._accumulatedFinal = '';
|
||||
this._pendingInterim = '';
|
||||
this._lastTranscript = '';
|
||||
this._hasReceivedResult = false;
|
||||
this._recordingStartedAt = Date.now();
|
||||
this._updateButtons('recording');
|
||||
this._showPreview('Listening...');
|
||||
this._startDurationTimer();
|
||||
try {
|
||||
this._webSpeechRunStartedAt = Date.now();
|
||||
this.recognition.start();
|
||||
} catch (e) {
|
||||
// InvalidStateError = already started — ignore. Other errors = genuine failure.
|
||||
if (e.name !== 'InvalidStateError') {
|
||||
this.stop(true);
|
||||
this.stop();
|
||||
app.showToast('Voice input failed to start: ' + e.message, 'error');
|
||||
return;
|
||||
}
|
||||
}
|
||||
this._resetSilenceTimeout();
|
||||
// SpeechRecognition owns its microphone. A second getUserMedia capture for
|
||||
// a cosmetic meter can take audio away from Android's recognition service.
|
||||
// Streaming providers still meter the same stream they transcribe.
|
||||
this._drawLevelMeter();
|
||||
// Get mic stream for level meter (non-blocking — level meter is cosmetic)
|
||||
navigator.mediaDevices?.getUserMedia({ audio: true }).then(stream => {
|
||||
if (this.isRecording && this._activeProvider === 'webspeech') {
|
||||
this._webSpeechStream = stream;
|
||||
this._startLevelMeter(stream);
|
||||
} else {
|
||||
stream.getTracks().forEach(t => t.stop());
|
||||
}
|
||||
}).catch(() => { /* level meter just won't show */ });
|
||||
// Haptic feedback on mobile
|
||||
if (navigator.vibrate) navigator.vibrate(50);
|
||||
},
|
||||
|
||||
stop(providerFinished = false) {
|
||||
stop() {
|
||||
if (!this.isRecording) return;
|
||||
if (this._webSpeechRestartTimer !== null) {
|
||||
clearTimeout(this._webSpeechRestartTimer);
|
||||
this._webSpeechRestartTimer = null;
|
||||
// The recognizer already ended; no onend remains to wait for.
|
||||
providerFinished = true;
|
||||
}
|
||||
// Claude delivers its final transcript after finalize. Keep accepting it
|
||||
// until that frame (or onEnd) completes this recording.
|
||||
if (this._activeProvider === 'claude' && !providerFinished && ClaudeVoiceProvider._ws?.readyState === WebSocket.OPEN) {
|
||||
ClaudeVoiceProvider.stop();
|
||||
return;
|
||||
}
|
||||
if (this._activeProvider === 'webspeech' && !providerFinished) {
|
||||
if (this._webSpeechStopping) return;
|
||||
this._webSpeechStopping = true;
|
||||
clearTimeout(this.silenceTimeout);
|
||||
// stop() requests a result; Android may deliver it only before onend.
|
||||
// Keep accepting results, with a backstop for engines that never end.
|
||||
this._webSpeechStopTimer = setTimeout(() => {
|
||||
try { this.recognition?.abort(); } catch (_e) { /* already ended */ }
|
||||
this.stop(true);
|
||||
}, 3000);
|
||||
try { this.recognition?.stop(); } catch (_e) { this.stop(true); }
|
||||
return;
|
||||
}
|
||||
clearTimeout(this._webSpeechStopTimer);
|
||||
this._webSpeechStopTimer = null;
|
||||
this._webSpeechStopping = false;
|
||||
this.isRecording = false;
|
||||
const text = this._accumulatedFinal + this._pendingInterim;
|
||||
this._accumulatedFinal = '';
|
||||
this._pendingInterim = '';
|
||||
if (text) this._insertText(text);
|
||||
clearTimeout(this.silenceTimeout);
|
||||
clearTimeout(this._stabilityTimer);
|
||||
this.silenceTimeout = null;
|
||||
this._stabilityTimer = null;
|
||||
this._retryCount = 0;
|
||||
this._stopDurationTimer();
|
||||
this._stopLevelMeter();
|
||||
@@ -953,10 +863,21 @@ const VoiceInput = {
|
||||
this._hidePreview();
|
||||
|
||||
if (this._activeProvider === 'claude') {
|
||||
// Completion received, or forced teardown: callbacks must not outlive it.
|
||||
ClaudeVoiceProvider._cleanup();
|
||||
// Finalize, don't hang up: the last transcript arrives after the audio does.
|
||||
ClaudeVoiceProvider.stop();
|
||||
} else if (this._activeProvider === 'deepgram') {
|
||||
DeepgramProvider.stop();
|
||||
} else if (this._activeProvider === 'webspeech') {
|
||||
try {
|
||||
this.recognition?.stop();
|
||||
} catch (_e) {
|
||||
// Already stopped — ignore
|
||||
}
|
||||
// Stop the mic stream we opened for the level meter
|
||||
if (this._webSpeechStream) {
|
||||
this._webSpeechStream.getTracks().forEach(t => t.stop());
|
||||
this._webSpeechStream = null;
|
||||
}
|
||||
}
|
||||
this._activeProvider = null;
|
||||
|
||||
@@ -964,44 +885,41 @@ const VoiceInput = {
|
||||
if (navigator.vibrate) navigator.vibrate([30, 50, 30]);
|
||||
},
|
||||
|
||||
_onWebSpeechSound(active) {
|
||||
if (!this.isRecording || this._activeProvider !== 'webspeech' || this._webSpeechStopping) return;
|
||||
this._webSpeechSoundActive = active;
|
||||
if (!active) this._webSpeechActivityUntil = 0;
|
||||
},
|
||||
|
||||
_onWebSpeechResult(event) {
|
||||
if (!this.isRecording) return;
|
||||
this._hasReceivedResult = true;
|
||||
this._resetSilenceTimeout();
|
||||
// results is the current run's complete snapshot, not a list of new words.
|
||||
// Rebuild by result position so repeats/corrections replace the same text.
|
||||
// Never deduplicate by text: "go go" and repeated phrases can be intentional.
|
||||
const finals = [];
|
||||
const interims = [];
|
||||
for (let i = 0; i < event.results.length; i++) {
|
||||
const result = event.results[i];
|
||||
const text = result[0]?.transcript?.trim();
|
||||
if (text) (result.isFinal ? finals : interims).push(text);
|
||||
let interim = '';
|
||||
let finalText = '';
|
||||
for (let i = event.resultIndex; i < event.results.length; i++) {
|
||||
const transcript = event.results[i][0].transcript;
|
||||
if (event.results[i].isFinal) {
|
||||
finalText += transcript;
|
||||
} else {
|
||||
interim += transcript;
|
||||
}
|
||||
}
|
||||
|
||||
if (finalText) {
|
||||
this._accumulatedFinal += finalText;
|
||||
this._hidePreview();
|
||||
this._insertText(this._accumulatedFinal);
|
||||
this.stop();
|
||||
} else if (interim) {
|
||||
const display = this._accumulatedFinal + interim;
|
||||
this._showPreview(display);
|
||||
// iOS Safari workaround: isFinal is always false.
|
||||
// Detect when interim results stop changing for 750ms → treat as final.
|
||||
this._iosStabilityCheck(interim);
|
||||
}
|
||||
this._accumulatedFinal = [this._webSpeechCommitted, ...finals].filter(Boolean).join(' ');
|
||||
const interim = interims.join(' ');
|
||||
this._hasReceivedResult ||= finals.length > 0 || !!interim;
|
||||
this._pendingInterim = interim ? (this._accumulatedFinal ? ' ' : '') + interim : '';
|
||||
this._showPreview(this._accumulatedFinal + this._pendingInterim);
|
||||
// Some engines omit sound events; transcript updates are also real evidence
|
||||
// of speech activity. This is an activity animation, not an amplitude meter.
|
||||
if (finals.length || interim) this._webSpeechActivityUntil = Date.now() + 600;
|
||||
},
|
||||
|
||||
_onWebSpeechError(event) {
|
||||
// During auto-retry, 'aborted' and 'no-speech' errors are expected — ignore them
|
||||
if (this._retryCount > 0 && (event.error === 'aborted' || event.error === 'no-speech')) return;
|
||||
|
||||
// Browser silence events must not override an extended/manual deadline.
|
||||
if (event.error === 'no-speech' && this.getSilenceTimeoutMs(3000) !== 3000) return;
|
||||
|
||||
const wasRecording = this.isRecording;
|
||||
this.stop(true);
|
||||
this.stop();
|
||||
if (!wasRecording) return;
|
||||
|
||||
switch (event.error) {
|
||||
@@ -1025,45 +943,27 @@ const VoiceInput = {
|
||||
_onWebSpeechEnd() {
|
||||
// Recognition ended (browser auto-stopped or we called stop())
|
||||
if (!this.isRecording) return;
|
||||
this._onWebSpeechSound(false);
|
||||
if (this._webSpeechStopping) {
|
||||
this.stop(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// Only a run that ends empty right after start() counts toward the cap: that
|
||||
// is a recognizer that cannot listen. A silent run of normal length is the
|
||||
// engine's own no-speech window, and 30 s or Manual must outlast many of them.
|
||||
const empty = !this._hasReceivedResult;
|
||||
const instant = empty && Date.now() - this._webSpeechRunStartedAt < this.WEB_SPEECH_INSTANT_END_MS;
|
||||
this._retryCount = instant ? this._retryCount + 1 : 0;
|
||||
if (this._retryCount > 2) {
|
||||
this.stop(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// Native endpoints must not extend the configured silence deadline.
|
||||
const delay = this.getSilenceTimeoutMs(3000);
|
||||
if (empty || this._webSpeechSingleUtterance || delay === 0 || delay > 3000) {
|
||||
// Commit this run once before the next run resets its result indexes.
|
||||
this._webSpeechCommitted = (this._accumulatedFinal + this._pendingInterim).trim();
|
||||
this._accumulatedFinal = this._webSpeechCommitted;
|
||||
this._pendingInterim = '';
|
||||
const restart = () => {
|
||||
this._webSpeechRestartTimer = null;
|
||||
if (!this.isRecording || this._webSpeechStopping) return;
|
||||
this._hasReceivedResult = false;
|
||||
this._webSpeechRunStartedAt = Date.now();
|
||||
try { this.recognition.start(); } catch (_e) { this.stop(true); }
|
||||
};
|
||||
// A broken or unavailable recognizer must not spin forever in Manual mode.
|
||||
if (empty) this._webSpeechRestartTimer = setTimeout(restart, 250);
|
||||
else restart();
|
||||
const elapsed = Date.now() - this._recordingStartedAt;
|
||||
// Web Speech API often fires onend prematurely on the first attempt (< 500ms, no results).
|
||||
// Auto-retry up to 2 times to avoid the "needs two clicks" problem.
|
||||
if (elapsed < 500 && !this._hasReceivedResult && this._retryCount < 2) {
|
||||
this._retryCount++;
|
||||
try {
|
||||
this.recognition.start();
|
||||
} catch (_e) {
|
||||
// If restart fails, fall through to stop
|
||||
if (this._accumulatedFinal) this._insertText(this._accumulatedFinal);
|
||||
this.stop();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Genuine end — finalize any accumulated text
|
||||
this.stop(true);
|
||||
if (this._accumulatedFinal) {
|
||||
this._insertText(this._accumulatedFinal);
|
||||
}
|
||||
this.stop();
|
||||
},
|
||||
|
||||
/** The session this dictation belongs to (see _targetSessionId). */
|
||||
@@ -1238,14 +1138,30 @@ const VoiceInput = {
|
||||
|
||||
_resetSilenceTimeout() {
|
||||
clearTimeout(this.silenceTimeout);
|
||||
this.silenceTimeout = null;
|
||||
const delay = this.getSilenceTimeoutMs(3000);
|
||||
if (delay === 0) return;
|
||||
this.silenceTimeout = setTimeout(() => {
|
||||
if (this.isRecording) {
|
||||
// Finalize any accumulated text before stopping
|
||||
if (this._accumulatedFinal) {
|
||||
this._insertText(this._accumulatedFinal);
|
||||
}
|
||||
this.stop();
|
||||
}
|
||||
}, delay);
|
||||
}, 3000);
|
||||
},
|
||||
|
||||
_iosStabilityCheck(transcript) {
|
||||
if (transcript !== this._lastTranscript) {
|
||||
this._lastTranscript = transcript;
|
||||
clearTimeout(this._stabilityTimer);
|
||||
this._stabilityTimer = setTimeout(() => {
|
||||
if (this.isRecording) {
|
||||
const finalText = this._accumulatedFinal + transcript;
|
||||
this._hidePreview();
|
||||
this._insertText(finalText);
|
||||
this.stop();
|
||||
}
|
||||
}, 750);
|
||||
}
|
||||
},
|
||||
|
||||
_startDurationTimer() {
|
||||
@@ -1284,8 +1200,6 @@ const VoiceInput = {
|
||||
},
|
||||
|
||||
_stopLevelMeter() {
|
||||
this._webSpeechSoundActive = false;
|
||||
this._webSpeechActivityUntil = 0;
|
||||
if (this._levelAnimFrame) {
|
||||
cancelAnimationFrame(this._levelAnimFrame);
|
||||
this._levelAnimFrame = null;
|
||||
@@ -1302,21 +1216,13 @@ const VoiceInput = {
|
||||
},
|
||||
|
||||
_drawLevelMeter() {
|
||||
if (!this.isRecording) return;
|
||||
let rms = 0;
|
||||
if (this._activeProvider === 'webspeech') {
|
||||
// The speech API exposes activity events, but no microphone sample level.
|
||||
const active = !this._webSpeechStopping
|
||||
&& (this._webSpeechSoundActive || Date.now() < this._webSpeechActivityUntil);
|
||||
if (active) rms = 0.25 + 0.55 * (1 + Math.sin(Date.now() / 110)) / 2;
|
||||
} else {
|
||||
if (!this._analyser) return;
|
||||
const dataArray = new Uint8Array(this._analyser.frequencyBinCount);
|
||||
this._analyser.getByteFrequencyData(dataArray);
|
||||
let sum = 0;
|
||||
for (let i = 0; i < dataArray.length; i++) sum += dataArray[i] * dataArray[i];
|
||||
rms = Math.sqrt(sum / dataArray.length) / 255;
|
||||
}
|
||||
if (!this._analyser || !this.isRecording) return;
|
||||
const dataArray = new Uint8Array(this._analyser.frequencyBinCount);
|
||||
this._analyser.getByteFrequencyData(dataArray);
|
||||
// Compute RMS level 0-1
|
||||
let sum = 0;
|
||||
for (let i = 0; i < dataArray.length; i++) sum += dataArray[i] * dataArray[i];
|
||||
const rms = Math.sqrt(sum / dataArray.length) / 255;
|
||||
// Update the level bars in the preview
|
||||
const barsEl = this.previewEl?.querySelector('.voice-level-bars');
|
||||
if (barsEl) {
|
||||
@@ -1346,8 +1252,6 @@ const VoiceInput = {
|
||||
indicator.innerHTML = '<span class="voice-rec-dot"></span>';
|
||||
const barsEl = document.createElement('span');
|
||||
barsEl.className = 'voice-level-bars';
|
||||
barsEl.setAttribute('aria-label', this._activeProvider === 'webspeech' ? 'Speech activity' : 'Microphone level');
|
||||
barsEl.title = this._activeProvider === 'webspeech' ? 'Speech activity' : 'Microphone level';
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const bar = document.createElement('span');
|
||||
bar.className = 'voice-level-bar';
|
||||
@@ -1415,27 +1319,28 @@ const VoiceInput = {
|
||||
|
||||
/** Cleanup on SSE reconnect or page unload */
|
||||
cleanup() {
|
||||
// Hide the Send button first: stop(true) below may insert the words it
|
||||
// salvaged and show the button again for them.
|
||||
if (this.isRecording) this.stop();
|
||||
this._hideVoiceSendBtn();
|
||||
if (this.isRecording) {
|
||||
this.stop(true);
|
||||
try { this.recognition?.abort(); } catch (_e) { /* already ended */ }
|
||||
}
|
||||
DeepgramProvider._cleanup();
|
||||
ClaudeVoiceProvider._cleanup();
|
||||
this.recognition = null;
|
||||
this._activeProvider = null;
|
||||
this._stopDurationTimer();
|
||||
this._stopLevelMeter();
|
||||
if (this._webSpeechStream) {
|
||||
this._webSpeechStream.getTracks().forEach(t => t.stop());
|
||||
this._webSpeechStream = null;
|
||||
}
|
||||
if (this.previewEl) {
|
||||
this.previewEl.remove();
|
||||
this.previewEl = null;
|
||||
}
|
||||
clearTimeout(this.silenceTimeout);
|
||||
clearTimeout(this._stabilityTimer);
|
||||
this.silenceTimeout = null;
|
||||
this._stabilityTimer = null;
|
||||
this._accumulatedFinal = '';
|
||||
this._pendingInterim = '';
|
||||
this._lastTranscript = '';
|
||||
this._retryCount = 0;
|
||||
this._hasReceivedResult = false;
|
||||
}
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
{
|
||||
"version": "1.50.1",
|
||||
"version": "1.50.0",
|
||||
"highlights": [
|
||||
"A MacBook keeps working with the lid closed (#795)",
|
||||
"The lid logo breathes while agents work (#794)",
|
||||
"The logo keeps blinking with the lid closed (#801)",
|
||||
"Codeman and LogoLight take turns on the logo (#802)",
|
||||
"Dictation survives a thinking pause (#705)",
|
||||
"Sync cases across devices (#732)",
|
||||
"Delete a file from the Files panel (#792)",
|
||||
"Zoom the path picker's image preview (#791)"
|
||||
"Codeman for Mac (#763)",
|
||||
"Your MacBook's Apple logo blinks with your agents (#781)",
|
||||
"Install from the app, with no Terminal and no password (#773)",
|
||||
"The Files panel follows a worktree outside the session's folder (#780)",
|
||||
"A coding model that runs on your Mac (#784, experimental)",
|
||||
"Claude in the cloud, a model on your Mac (#784, experimental)",
|
||||
"Ralph starts from Session Options (#783)",
|
||||
"The Codeman agent skill is on by default (#787)"
|
||||
]
|
||||
}
|
||||
@@ -87,7 +87,6 @@ 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';
|
||||
@@ -1748,14 +1747,6 @@ 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 {
|
||||
@@ -2024,7 +2015,6 @@ 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).
|
||||
@@ -2033,24 +2023,12 @@ export function registerCaseRoutes(
|
||||
const { before, after } = await mutateSettingsFile((existing) => {
|
||||
const order = renameInOrder(existing.caseOrder, name, newName);
|
||||
const last = existing.lastUsedCase === name ? newName : undefined;
|
||||
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 (!order && !last) return undefined;
|
||||
return { ...existing, ...(order ? { caseOrder: order } : {}), ...(last ? { lastUsedCase: last } : {}) };
|
||||
});
|
||||
if (after) {
|
||||
// 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]);
|
||||
const keys = ['caseOrder', 'lastUsedCase'].filter((k) => after[k] !== before[k]);
|
||||
settingsUndo = Object.fromEntries(keys.map((k) => [k, before[k]]));
|
||||
sharedRenamed = after.caseSettings !== before.caseSettings;
|
||||
}
|
||||
}
|
||||
if (isLinked) {
|
||||
@@ -2071,12 +2049,7 @@ 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) => {
|
||||
const back = sharedRenamed ? renameCaseSettings(current.caseSettings, newName, name) : null;
|
||||
return { ...current, ...undo, ...(back ? { caseSettings: back } : {}) };
|
||||
}).catch(() => {});
|
||||
}
|
||||
if (undo) await mutateSettingsFile((current) => ({ ...current, ...undo })).catch(() => {});
|
||||
if (moveFolder) {
|
||||
const movedBack = await fs.rename(newPath, oldPath).then(
|
||||
() => true,
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
/**
|
||||
* @fileoverview File browser and streaming routes.
|
||||
* Provides directory listing, file content preview, raw file serving, tail
|
||||
* streaming, the File Viewer edit-mode write path (edit=1 read +
|
||||
* streaming, and the File Viewer edit-mode write path (edit=1 read +
|
||||
* PUT /api/sessions/:id/file-content; policy in src/config/file-editing.ts,
|
||||
* design in docs/file-viewer-edit-plan.md), and the Files panel's delete
|
||||
* (DELETE /api/sessions/:id/files?path=: one regular file, the edit route's
|
||||
* confinement and guards, symbolic links refused).
|
||||
* design in docs/file-viewer-edit-plan.md).
|
||||
*/
|
||||
|
||||
import { FastifyInstance, type FastifyReply } from 'fastify';
|
||||
@@ -21,7 +19,6 @@ import type {
|
||||
FilesystemBrowseEntry,
|
||||
FilesystemBrowseRoot,
|
||||
FilesystemPreviewKind,
|
||||
FileDeleteData,
|
||||
FileWriteData,
|
||||
} from '../../types.js';
|
||||
import { ApiErrorCode, createErrorResponse, getErrorMessage } from '../../types.js';
|
||||
@@ -2003,103 +2000,6 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
|
||||
};
|
||||
});
|
||||
|
||||
// The Files panel's delete button: remove ONE regular file from the session's workspace (or from
|
||||
// its outside worktree, for an absolute path inside it). A write surface like the edit route
|
||||
// above, so it takes that route's confinement and guards whole (ownership, realpath + workspace boundary, sensitive and blocked trees, `.git`), and adds
|
||||
// its own rule for links: a symbolic link is REFUSED, never followed. The confinement check
|
||||
// returns the link's realpath, which is its TARGET, so an `unlink` of that path deletes the file
|
||||
// the link points at and leaves the link dangling (#529's review: `AGENTS.md -> CLAUDE.md`
|
||||
// deleted CLAUDE.md). The final component is therefore read with `lstat` on the path as the
|
||||
// client named it, and only a path whose last part is a plain file goes on; `unlink` never
|
||||
// follows a final symlink, so a link swapped in after that check is removed rather than its
|
||||
// target, and the inode is compared again right before the unlink.
|
||||
app.delete('/api/sessions/:id/files', async (req): Promise<ApiResponse<FileDeleteData>> => {
|
||||
const { id } = req.params as { id: string };
|
||||
const { path: filePath } = req.query as { path?: string };
|
||||
const session = findSessionOrFail(ctx, id, req);
|
||||
// Ahead of any path check, as on the edit route: the validation below resolves against the
|
||||
// LOCAL filesystem, so a same-named local directory would take the delete.
|
||||
if (session.remote) {
|
||||
throwRouteError(400, ApiErrorCode.INVALID_INPUT, 'Deleting is not supported for remote (SSH) sessions');
|
||||
}
|
||||
// An adopted session has no workspace of ours: its folder is the neutral `/tmp`. The older
|
||||
// file surfaces keep serving it as a standing exception (root CLAUDE.md, Adopted tmux
|
||||
// sessions), which is not copied to a new surface, least of all one that deletes.
|
||||
if (session.isAdopted) {
|
||||
throwRouteError(400, ApiErrorCode.INVALID_INPUT, 'Deleting is not supported for adopted sessions');
|
||||
}
|
||||
// A repeated `?path=` arrives as an array.
|
||||
if (typeof filePath !== 'string' || !filePath) {
|
||||
throwRouteError(400, ApiErrorCode.INVALID_INPUT, 'Missing path parameter');
|
||||
}
|
||||
// #516: the confinement check below runs realpathSync, which freezes the server on a mount
|
||||
// that stopped answering. Probe the folders it will touch first, bounded.
|
||||
const scope = await withGuardedOutsideWorktree({
|
||||
workingDir: session.workingDir,
|
||||
liveWorktree: session.liveWorktree,
|
||||
});
|
||||
const worktreeRoot = scope.liveWorktree?.outside && isAbsolute(filePath) ? scope.liveWorktree.path : null;
|
||||
for (const folder of worktreeRoot ? [session.workingDir, worktreeRoot] : [session.workingDir]) {
|
||||
if ((await probePathKind(folder, { pastCap: true })) === 'unknown') {
|
||||
throwRouteError(
|
||||
422,
|
||||
ApiErrorCode.OPERATION_FAILED,
|
||||
describeUnknownPath('Workspace', folder, { pastCap: true })
|
||||
);
|
||||
}
|
||||
}
|
||||
const validated = validateLocalSessionFilePath(scope, filePath);
|
||||
if (!validated) {
|
||||
// Missing files, traversal and symlink escapes alike, as on every other file route.
|
||||
throwRouteError(404, ApiErrorCode.NOT_FOUND, 'File not found');
|
||||
}
|
||||
const { resolvedPath, relativePath } = validated;
|
||||
// The path as the client named it: `resolve` keeps an absolute (outside worktree) path as is.
|
||||
const namedPath = resolve(session.workingDir, filePath);
|
||||
const guard = await loadAttachmentGuardConfig();
|
||||
if (
|
||||
isSensitivePath(resolvedPath) ||
|
||||
isSensitivePath(namedPath) ||
|
||||
isBlockedAttachmentPath(resolvedPath, guard.blockedTrees) ||
|
||||
isBlockedAttachmentPath(namedPath, guard.blockedTrees)
|
||||
) {
|
||||
throwRouteError(403, ApiErrorCode.FORBIDDEN, 'Deleting this file is blocked');
|
||||
}
|
||||
// Both spellings: a `.git` reached through a linked folder, and a linked `.git`.
|
||||
if (isDeniedEditRelativePath(relativePath) || isDeniedEditRelativePath(relative(session.workingDir, namedPath))) {
|
||||
throwRouteError(403, ApiErrorCode.FORBIDDEN, 'Files under .git cannot be deleted');
|
||||
}
|
||||
const named = await fs.lstat(namedPath).catch(() => undefined);
|
||||
if (!named) {
|
||||
throwRouteError(404, ApiErrorCode.NOT_FOUND, 'File not found');
|
||||
}
|
||||
if (named.isSymbolicLink()) {
|
||||
throwRouteError(
|
||||
400,
|
||||
ApiErrorCode.INVALID_INPUT,
|
||||
'Symbolic links are not deleted from the Files panel (deleting one here would remove the file it points to)'
|
||||
);
|
||||
}
|
||||
if (!named.isFile()) {
|
||||
throwRouteError(400, ApiErrorCode.INVALID_INPUT, 'Only regular files can be deleted');
|
||||
}
|
||||
// The same file the checks were made on: realpath of a path whose last part is no link names
|
||||
// the same inode. A change in between (the file replaced) refuses rather than guesses.
|
||||
const real = await fs.lstat(resolvedPath).catch(() => undefined);
|
||||
if (!real || !real.isFile() || real.ino !== named.ino || real.dev !== named.dev) {
|
||||
throwRouteError(409, ApiErrorCode.CONFLICT, 'The file changed while it was being deleted; reload and try again');
|
||||
}
|
||||
try {
|
||||
await fs.unlink(resolvedPath);
|
||||
} catch (err) {
|
||||
const code = (err as NodeJS.ErrnoException).code;
|
||||
if (code === 'ENOENT') throwRouteError(404, ApiErrorCode.NOT_FOUND, 'File not found');
|
||||
// The errno text names the resolved path, so only its code goes back.
|
||||
throwRouteError(500, ApiErrorCode.OPERATION_FAILED, `Failed to delete the file (${code ?? 'error'})`);
|
||||
}
|
||||
return { success: true, data: { path: filePath } };
|
||||
});
|
||||
|
||||
// Get file content for preview (File Browser)
|
||||
app.get('/api/sessions/:id/file-content', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
|
||||
@@ -6,15 +6,19 @@
|
||||
* |----------------------------------|-----------------------------|------------------------------------------------|
|
||||
* | `GET /api/local-model` | none | `{ hardware, recommendation, catalogUpdated, install }` |
|
||||
* | `POST /api/local-model/install` | `LocalModelInstallRequest` | the install view; 400, 409 |
|
||||
* | `POST /api/local-model/use` | `LocalModelUseRequest` | the install view; 400, 409 |
|
||||
* | `POST /api/local-model/cancel` | none | the install view; 409 |
|
||||
* | `POST /api/local-model/resume` | `LocalModelResumeRequest` | the install view; 409 |
|
||||
* | `POST /api/local-model/runtime` | `LocalModelRuntimeRequest` | the install view; 409 |
|
||||
* | `DELETE /api/local-model` | `LocalModelRemoveRequest` | the install view; 409 |
|
||||
* | `DELETE /api/local-model/models` | `LocalModelDeleteRequest` | the install view; 400, 409, 500 |
|
||||
*
|
||||
* The GET is built from the hardware probe, the catalogue, the harness facts and the pure
|
||||
* `recommend()`, plus the `install` object of the install service
|
||||
* (`LocalModelService.view()`). Nothing is reformatted here: every size text and every
|
||||
* sentence in the payload was chosen by the modules that produced it. There is no SSE event:
|
||||
* (`LocalModelService.view()`). The one thing joined here: a row of the recommendation that is
|
||||
* a checked model of the library is marked (`Candidate.library`, by `markLibraryCandidates()`).
|
||||
* Nothing is reformatted: every size text and every sentence in the payload was chosen by the
|
||||
* modules that produced it. There is no SSE event:
|
||||
* the window polls the GET once a second while it is open.
|
||||
*
|
||||
* EXPERIMENTAL (docs/versioning-policy.md, "Experimental local model routes"): the routes and
|
||||
@@ -45,9 +49,12 @@
|
||||
* or of the endpoint store that was refused (a file that does not parse) is a 409 without
|
||||
* `data`, with the fix.
|
||||
* - The verbs do the work in the install service; this file only gates, parses and maps.
|
||||
* `DELETE`, `runtime` and `resume` look at the live sessions: they hand their names to the
|
||||
* service, which refuses a removal, a stop and a change of port while one of them still
|
||||
* uses the model.
|
||||
* `DELETE /api/local-model`, `runtime`, `resume`, `install` and `use` look at the live
|
||||
* sessions: they hand their names to the service, which refuses a removal, a stop, a change
|
||||
* of port, another model and a switch while one of them still uses the model.
|
||||
* - `DELETE /api/local-model/models` takes a model NAME in its body, never a path: the schema
|
||||
* admits only `name:tag`, and the service deletes only a name of its own listing, through
|
||||
* the runtime.
|
||||
* - Every dependency is injected (`LocalModelRouteDeps`), so a route test spawns nothing and
|
||||
* does not need a Mac: it passes its own hardware, harness facts and service.
|
||||
*/
|
||||
@@ -83,16 +90,19 @@ import { buildHarnessFacts } from '../../local-model/harness-facts.js';
|
||||
import {
|
||||
formatInstallMessage,
|
||||
getLocalModelService,
|
||||
markLibraryCandidates,
|
||||
type LocalModelActor,
|
||||
type LocalModelService,
|
||||
type LocalModelVerbResult,
|
||||
} from '../../local-model/install-job.js';
|
||||
import { recommend } from '../../local-model/recommend.js';
|
||||
import {
|
||||
LocalModelDeleteSchema,
|
||||
LocalModelInstallSchema,
|
||||
LocalModelRemoveSchema,
|
||||
LocalModelResumeSchema,
|
||||
LocalModelRuntimeSchema,
|
||||
LocalModelUseSchema,
|
||||
} from '../schemas.js';
|
||||
import { SettingsWriteRefusedError, settingsWriteErrorResponse } from '../settings-writer.js';
|
||||
|
||||
@@ -156,7 +166,7 @@ export interface LocalModelConflictResponse {
|
||||
data: LocalModelConflict;
|
||||
}
|
||||
|
||||
/** The 400 (or 507) of a verb the service refused with a sentence: the error envelope plus that sentence as `data`. */
|
||||
/** The 400 (or 500, or 507) of a verb the service refused with a sentence: the error envelope plus that sentence as `data`. */
|
||||
export interface LocalModelRefusalResponse {
|
||||
success: false;
|
||||
/** The sentence in English, its slots filled. */
|
||||
@@ -251,8 +261,12 @@ export function registerLocalModelRoutes(
|
||||
// The measured budget once the runtime reported it, and the runtime download that an
|
||||
// install would still add: what turns the estimate into the figures of the consent.
|
||||
const options = await service.recommendOptions();
|
||||
const recommendation = resolved.recommend(hardware, catalog, harnessFacts, options);
|
||||
const install = await service.view();
|
||||
// A row that is already a checked model on this Mac says so: choosing it is a switch.
|
||||
const recommendation = markLibraryCandidates(
|
||||
resolved.recommend(hardware, catalog, harnessFacts, options),
|
||||
install
|
||||
);
|
||||
return { success: true, data: { hardware, recommendation, catalogUpdated: catalog.updated, install } };
|
||||
} catch (err) {
|
||||
reply.code(500);
|
||||
@@ -268,7 +282,17 @@ export function registerLocalModelRoutes(
|
||||
const denied = adminOnly(req, reply);
|
||||
if (denied) return denied;
|
||||
const body = parseBody(LocalModelInstallSchema, req.body);
|
||||
return runVerb(reply, () => resolved.service().install(body, actorOf(req)));
|
||||
// Asked at the moment of the request: another model takes the runtime from a live session.
|
||||
const sessionNames = sessionNamesUsingEndpoint(ctx.sessions.values());
|
||||
return runVerb(reply, () => resolved.service().install(body, actorOf(req), sessionNames));
|
||||
});
|
||||
|
||||
app.post('/api/local-model/use', async (req, reply): Promise<LocalModelVerbResponse> => {
|
||||
const denied = adminOnly(req, reply);
|
||||
if (denied) return denied;
|
||||
const body = parseBody(LocalModelUseSchema, req.body);
|
||||
const sessionNames = sessionNamesUsingEndpoint(ctx.sessions.values());
|
||||
return runVerb(reply, () => resolved.service().use(body, actorOf(req), sessionNames));
|
||||
});
|
||||
|
||||
app.post('/api/local-model/cancel', async (req, reply): Promise<LocalModelVerbResponse> => {
|
||||
@@ -303,4 +327,11 @@ export function registerLocalModelRoutes(
|
||||
const sessionNames = sessionNamesUsingEndpoint(ctx.sessions.values());
|
||||
return runVerb(reply, () => resolved.service().remove(body, sessionNames, actorOf(req)));
|
||||
});
|
||||
|
||||
app.delete('/api/local-model/models', async (req, reply): Promise<LocalModelVerbResponse> => {
|
||||
const denied = adminOnly(req, reply);
|
||||
if (denied) return denied;
|
||||
const body = parseBody(LocalModelDeleteSchema, req.body);
|
||||
return runVerb(reply, () => resolved.service().deleteLibraryModel(body, actorOf(req)));
|
||||
});
|
||||
}
|
||||
@@ -49,7 +49,6 @@ 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';
|
||||
|
||||
@@ -161,8 +160,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`, `lastUsedCase`, `lastPickedCase` and `caseSettings` 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` and `lastUsedCase` 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 {
|
||||
@@ -170,8 +169,6 @@ 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>) };
|
||||
@@ -1144,18 +1141,8 @@ 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;
|
||||
// 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 };
|
||||
const { before: existing } = await mutateSettingsFile((current) => ({ ...current, ...settingsToStore }));
|
||||
const merged = { ...existing, ...settingsToStore };
|
||||
|
||||
// tmux 3.7+ resizes tracked panes; older versions apply this to new panes.
|
||||
// Already-evicted history cannot be recovered on either version.
|
||||
@@ -1257,12 +1244,6 @@ 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);
|
||||
}
|
||||
});
|
||||
|
||||
+6
-14
@@ -1461,18 +1461,6 @@ 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(),
|
||||
@@ -1795,8 +1783,6 @@ export const SettingsUpdateSchema = z
|
||||
language: z.string().max(20).optional(),
|
||||
keyterms: z.string().max(500).optional(),
|
||||
insertMode: z.string().max(20).optional(),
|
||||
/** Silence timeout in seconds; 0 disables automatic stopping. Omitted keeps provider defaults. */
|
||||
silenceTimeout: z.union([z.literal(0), z.literal(3), z.literal(6), z.literal(15), z.literal(30)]).optional(),
|
||||
})
|
||||
.optional(),
|
||||
// Run mode preference (cross-device sync)
|
||||
@@ -2519,6 +2505,12 @@ export const LocalModelRuntimeSchema = z
|
||||
/** DELETE /api/local-model. The body may be empty. */
|
||||
export const LocalModelRemoveSchema = z.object({ removeModel: z.boolean().optional() }).strict();
|
||||
|
||||
/** POST /api/local-model/use: a checked model of the library to switch to. */
|
||||
export const LocalModelUseSchema = z.object({ modelId: localModelId }).strict();
|
||||
|
||||
/** DELETE /api/local-model/models: one model of the store, by its name. Never a path. */
|
||||
export const LocalModelDeleteSchema = z.object({ modelId: localModelId }).strict();
|
||||
|
||||
/**
|
||||
* A shell-safe bare word, mirroring `config/cli-registry/schema.ts`'s own `shellToken` —
|
||||
* duplicated rather than imported, since the REAL safety boundary for anything built from
|
||||
|
||||
@@ -13,10 +13,9 @@
|
||||
* - `{"type":"KeepAlive"}` on open and every 8s, or upstream drops the socket
|
||||
* between utterances.
|
||||
* - Audio frames go up as raw binary.
|
||||
* - Downstream, `TranscriptText`/`TranscriptInterim` carry the current utterance
|
||||
* so far (each frame supersedes the previous one; they are not deltas to
|
||||
* concatenate), and `TranscriptEndpoint` promotes the pending interim to final,
|
||||
* which ends that utterance.
|
||||
* - Downstream, `TranscriptText`/`TranscriptInterim` carry the RUNNING transcript
|
||||
* (each frame supersedes the previous one — they are not deltas to concatenate),
|
||||
* and `TranscriptEndpoint` promotes the pending interim to final.
|
||||
* - `{"type":"CloseStream"}` finalizes; the endpoint frame that follows is the
|
||||
* last transcript, so `finalize()` waits briefly for it rather than closing.
|
||||
*
|
||||
@@ -245,7 +244,7 @@ export class VoiceStreamRelay {
|
||||
switch (msg.type) {
|
||||
case 'TranscriptText':
|
||||
case 'TranscriptInterim': {
|
||||
// Each frame is the current utterance so far, not a delta; endpoints commit it.
|
||||
// Each frame is the whole running transcript, not a delta.
|
||||
if (typeof msg.data === 'string' && msg.data) {
|
||||
this.pendingTranscript = msg.data;
|
||||
this.opts.onTranscript(msg.data, false);
|
||||
|
||||
@@ -96,7 +96,7 @@ async function boot(stored?: Settings) {
|
||||
};
|
||||
var KeyboardHandler = {}, SwipeHandler = {}, DeepgramProvider = {}, NotificationManager = function () {};
|
||||
var KeyboardAccessoryBar = { setMode() {} };
|
||||
var VoiceInput = { SILENCE_TIMEOUT_SECONDS: [0, 3, 6, 15, 30], _getDeepgramConfig: () => ({}), _saveDeepgramConfig() {}, refreshClaudeStatus: () => Promise.resolve() };
|
||||
var VoiceInput = { _getDeepgramConfig: () => ({}), _saveDeepgramConfig() {}, refreshClaudeStatus: () => Promise.resolve() };
|
||||
var FocusTrap = function () { this.activate = () => {}; this.deactivate = () => {}; };
|
||||
var DEFAULT_VOICE_KEYTERMS = '';`,
|
||||
...SCRIPTS,
|
||||
|
||||
@@ -173,11 +173,7 @@ 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}`, lastPickedCase: `case-${n}` }),
|
||||
'displayName',
|
||||
],
|
||||
['settings.json', (n: number) => JSON.stringify({ displayName: 'x', lastUsedCase: `case-${n}` }), 'displayName'],
|
||||
[
|
||||
'intents.json',
|
||||
(n: number) => JSON.stringify({ profiles: [{ key: 'k', goals: 'g', updatedAt: n, recentPrompts: [`p${n}`] }] }),
|
||||
|
||||
@@ -1,234 +0,0 @@
|
||||
/**
|
||||
* @fileoverview Runs the real lid helper of the Codeman app for Mac
|
||||
* (packages/desktop/macos/keep-awake-lid.sh) in bash against a stub `pmset` that
|
||||
* records what it was asked to do. The helper runs as root from a LaunchDaemon, so the
|
||||
* cases that matter are the ones where it must NOT act: a stale, dead, foreign or
|
||||
* symlinked request, a Mac on battery, and an administrator's own `disablesleep 1`,
|
||||
* which it must never switch off.
|
||||
*
|
||||
* Only the BSD/GNU-portable tools the script uses (find -mmin, ps -o uid=, ls -ln) run
|
||||
* here, so this passes on Linux and macOS alike. The app embeds the same file
|
||||
* (include_str! in packages/desktop/src/keep_awake.rs), so this is the script that
|
||||
* gets installed. Port: none.
|
||||
*/
|
||||
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import {
|
||||
chmodSync,
|
||||
existsSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
symlinkSync,
|
||||
utimesSync,
|
||||
writeFileSync,
|
||||
} from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
const HELPER = fileURLToPath(new URL('../packages/desktop/macos/keep-awake-lid.sh', import.meta.url));
|
||||
|
||||
let dir: string;
|
||||
let stateDir: string;
|
||||
let request: string;
|
||||
|
||||
/**
|
||||
* Stub pmset: `-g` prints the stored SleepDisabled, `-g batt` the stored power source,
|
||||
* `-a disablesleep N` stores N and logs it.
|
||||
*/
|
||||
function writeStubPmset(initial: '0' | '1' | null, power: 'AC Power' | 'Battery Power' | null = 'AC Power') {
|
||||
const value = join(dir, 'sleepdisabled');
|
||||
if (initial !== null) writeFileSync(value, initial);
|
||||
const source = join(dir, 'power');
|
||||
if (power !== null) writeFileSync(source, power);
|
||||
const stub = join(dir, 'pmset');
|
||||
writeFileSync(
|
||||
stub,
|
||||
`#!/bin/bash
|
||||
if [ "$1" = "-g" ] && [ "$2" = "batt" ]; then
|
||||
[ -f "${source}" ] || exit 1
|
||||
echo "Now drawing from '$(cat "${source}")'"
|
||||
exit 0
|
||||
fi
|
||||
if [ "$1" = "-g" ]; then
|
||||
echo "System-wide power settings:"
|
||||
[ -f "${value}" ] && printf ' SleepDisabled\\t\\t%s\\n' "$(cat "${value}")"
|
||||
echo "Currently in use:"
|
||||
echo " sleep 1"
|
||||
exit 0
|
||||
fi
|
||||
if [ "$1" = "-a" ] && [ "$2" = "disablesleep" ]; then
|
||||
printf '%s' "$3" > "${value}"
|
||||
echo "disablesleep $3" >> "${join(dir, 'pmset.log')}"
|
||||
exit 0
|
||||
fi
|
||||
exit 1
|
||||
`
|
||||
);
|
||||
chmodSync(stub, 0o755);
|
||||
return stub;
|
||||
}
|
||||
|
||||
const setPower = (power: 'AC Power' | 'Battery Power') => writeFileSync(join(dir, 'power'), power);
|
||||
|
||||
function run() {
|
||||
const result = spawnSync('bash', [HELPER, request], {
|
||||
encoding: 'utf-8',
|
||||
env: {
|
||||
...process.env,
|
||||
CODEMAN_KEEPAWAKE_PMSET: join(dir, 'pmset'),
|
||||
CODEMAN_KEEPAWAKE_STATE_DIR: stateDir,
|
||||
},
|
||||
});
|
||||
expect(result.status, result.stderr).toBe(0);
|
||||
}
|
||||
|
||||
const calls = () => (existsSync(join(dir, 'pmset.log')) ? readFileSync(join(dir, 'pmset.log'), 'utf-8') : '');
|
||||
const sleepDisabled = () => readFileSync(join(dir, 'sleepdisabled'), 'utf-8');
|
||||
const owned = () => existsSync(join(stateDir, 'keep-awake.owned'));
|
||||
/** What the app writes: its own pid and a newline. This test process stands in for it. */
|
||||
const requestFromLiveApp = () => writeFileSync(request, `${process.pid}\n`);
|
||||
|
||||
beforeEach(() => {
|
||||
dir = mkdtempSync(join(tmpdir(), 'keep-awake-lid-'));
|
||||
stateDir = join(dir, 'state');
|
||||
request = join(dir, 'keep-awake-lid.pid');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('keep-awake-lid.sh', () => {
|
||||
it('does nothing without a request', () => {
|
||||
writeStubPmset('0');
|
||||
run();
|
||||
expect(calls()).toBe('');
|
||||
expect(owned()).toBe(false);
|
||||
});
|
||||
|
||||
it('applies disablesleep for a fresh request from a live process, then undoes it when the request goes', () => {
|
||||
writeStubPmset('0');
|
||||
requestFromLiveApp();
|
||||
run();
|
||||
expect(sleepDisabled()).toBe('1');
|
||||
expect(owned()).toBe(true);
|
||||
|
||||
run(); // steady state: no repeated pmset call
|
||||
expect(calls()).toBe('disablesleep 1\n');
|
||||
|
||||
rmSync(request);
|
||||
run();
|
||||
expect(sleepDisabled()).toBe('0');
|
||||
expect(owned()).toBe(false);
|
||||
expect(calls()).toBe('disablesleep 1\ndisablesleep 0\n');
|
||||
});
|
||||
|
||||
it('never acts on battery, and lets go when the adapter is pulled', () => {
|
||||
writeStubPmset('0', 'Battery Power');
|
||||
requestFromLiveApp();
|
||||
run();
|
||||
expect(calls()).toBe('');
|
||||
|
||||
setPower('AC Power');
|
||||
run();
|
||||
expect(sleepDisabled()).toBe('1');
|
||||
|
||||
setPower('Battery Power');
|
||||
run();
|
||||
expect(sleepDisabled()).toBe('0');
|
||||
expect(owned()).toBe(false);
|
||||
});
|
||||
|
||||
it('reads an unanswerable power source as battery', () => {
|
||||
writeStubPmset('0', null);
|
||||
requestFromLiveApp();
|
||||
run();
|
||||
expect(calls()).toBe('');
|
||||
});
|
||||
|
||||
it("never switches off an administrator's own disablesleep", () => {
|
||||
writeStubPmset('1');
|
||||
requestFromLiveApp();
|
||||
run();
|
||||
expect(calls()).toBe('');
|
||||
expect(owned()).toBe(false);
|
||||
rmSync(request);
|
||||
run();
|
||||
expect(sleepDisabled()).toBe('1');
|
||||
expect(calls()).toBe('');
|
||||
});
|
||||
|
||||
it("leaves an administrator's own disablesleep on when the adapter is pulled", () => {
|
||||
writeStubPmset('1', 'Battery Power');
|
||||
requestFromLiveApp();
|
||||
run();
|
||||
expect(calls()).toBe('');
|
||||
expect(sleepDisabled()).toBe('1');
|
||||
});
|
||||
|
||||
it('treats an absent SleepDisabled line as 0', () => {
|
||||
writeStubPmset(null);
|
||||
requestFromLiveApp();
|
||||
run();
|
||||
expect(sleepDisabled()).toBe('1');
|
||||
expect(owned()).toBe(true);
|
||||
});
|
||||
|
||||
it('re-applies after a reboot or OS update reset it, while the request stands', () => {
|
||||
writeStubPmset('0');
|
||||
requestFromLiveApp();
|
||||
run();
|
||||
writeFileSync(join(dir, 'sleepdisabled'), '0'); // the reset
|
||||
run();
|
||||
expect(sleepDisabled()).toBe('1');
|
||||
});
|
||||
|
||||
it('ignores a stale request (an app that hangs) and releases', () => {
|
||||
writeStubPmset('0');
|
||||
requestFromLiveApp();
|
||||
run();
|
||||
expect(sleepDisabled()).toBe('1');
|
||||
const threeMinutesAgo = (Date.now() - 3 * 60_000) / 1000;
|
||||
utimesSync(request, threeMinutesAgo, threeMinutesAgo);
|
||||
run();
|
||||
expect(sleepDisabled()).toBe('0');
|
||||
expect(owned()).toBe(false);
|
||||
});
|
||||
|
||||
it('releases at the next run once the app is gone, however fresh its file', () => {
|
||||
writeStubPmset('0');
|
||||
requestFromLiveApp();
|
||||
run();
|
||||
expect(sleepDisabled()).toBe('1');
|
||||
const gone = spawnSync('true').pid;
|
||||
writeFileSync(request, `${gone}\n`);
|
||||
run();
|
||||
expect(sleepDisabled()).toBe('0');
|
||||
expect(owned()).toBe(false);
|
||||
});
|
||||
|
||||
it('ignores a request whose pid is not running', () => {
|
||||
writeStubPmset('0');
|
||||
const gone = spawnSync('true').pid;
|
||||
writeFileSync(request, `${gone}\n`);
|
||||
run();
|
||||
expect(calls()).toBe('');
|
||||
});
|
||||
|
||||
it('ignores garbage and refuses a symlinked request', () => {
|
||||
writeStubPmset('0');
|
||||
writeFileSync(request, 'not-a-pid\n');
|
||||
run();
|
||||
expect(calls()).toBe('');
|
||||
|
||||
rmSync(request);
|
||||
const real = join(dir, 'elsewhere.pid');
|
||||
writeFileSync(real, `${process.pid}\n`);
|
||||
symlinkSync(real, request);
|
||||
run();
|
||||
expect(calls()).toBe('');
|
||||
});
|
||||
});
|
||||
@@ -30,9 +30,6 @@ interface FakeRow {
|
||||
dataset: Record<string, string>;
|
||||
addEventListener: (type: string, handler: ClickHandler) => void;
|
||||
click: () => void;
|
||||
// The row's delete button is looked up per row; the fake rows carry none (file-delete-ui.test.ts
|
||||
// drives it over a real DOM).
|
||||
querySelector: (selector: string) => null;
|
||||
}
|
||||
|
||||
interface FakeElement {
|
||||
@@ -108,7 +105,6 @@ function fakeElement(initialClasses: string[] = []): FakeElement {
|
||||
click() {
|
||||
clickHandler?.();
|
||||
},
|
||||
querySelector: () => null,
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1,264 +0,0 @@
|
||||
/**
|
||||
* @fileoverview The Files panel's delete button (salvaged from #529), in the browser module.
|
||||
*
|
||||
* #529 first rendered the button with an inline `onclick` that quoted the path inside
|
||||
* `decodeURIComponent('...')`. `encodeURIComponent` leaves `'` alone, so a file named
|
||||
* `x'-alert(1)-'.txt` ran script in Codeman's origin, and `it's.txt` was a syntax error whose
|
||||
* button did nothing. Workspace names come from agents and cloned repos, so the click is now
|
||||
* bound from the row's escaped `data-path`.
|
||||
*
|
||||
* The DOM is jsdom with `runScripts: 'dangerously'` on purpose: jsdom then compiles inline event
|
||||
* attributes the way a browser does, with `window.app` and `window.alert` present, so the old
|
||||
* markup would really call `alert` here and this test would fail on it. The escaper is the real
|
||||
* one from constants.js, not a stub. panels-ui.js loads via `vm` as in
|
||||
* test/file-browser-incomplete.test.ts. Port: N/A.
|
||||
*/
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import vm from 'node:vm';
|
||||
import { JSDOM } from 'jsdom';
|
||||
import { afterAll, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const PUBLIC = resolve(import.meta.dirname, '../src/web/public');
|
||||
const PANELS = readFileSync(resolve(PUBLIC, 'panels-ui.js'), 'utf8');
|
||||
const CONSTANTS = readFileSync(resolve(PUBLIC, 'constants.js'), 'utf8');
|
||||
const STYLES = readFileSync(resolve(PUBLIC, 'styles.css'), 'utf8');
|
||||
|
||||
// The shipped escaper: its map, its pattern and the function, cut from constants.js.
|
||||
const ESCAPE_START = CONSTANTS.indexOf('const _htmlEscapeMap');
|
||||
const ESCAPE_END = CONSTANTS.indexOf('\n}\n', CONSTANTS.indexOf('function escapeHtml(')) + 3;
|
||||
const ESCAPE_SRC = CONSTANTS.slice(ESCAPE_START, ESCAPE_END);
|
||||
|
||||
const HOSTILE = ["x'-alert(1)-'.txt", "it's.txt", 'a "b" <i>c</i> & d.txt', "');alert(2);('.txt"];
|
||||
const SESSION = 'sess-1';
|
||||
|
||||
const dom = new JSDOM(
|
||||
`<div id="fileBrowserPanel" class="visible"></div>
|
||||
<input id="fileBrowserSearch">
|
||||
<div id="fileBrowserStatus"></div>
|
||||
<div id="fileBrowserTree"></div>`,
|
||||
{ runScripts: 'dangerously' }
|
||||
);
|
||||
afterAll(() => dom.window.close());
|
||||
const document = dom.window.document;
|
||||
const win = dom.window as unknown as Record<string, unknown>;
|
||||
const treeEl = () => document.getElementById('fileBrowserTree') as HTMLElement;
|
||||
|
||||
interface FakeResponse {
|
||||
ok: boolean;
|
||||
status: number;
|
||||
json: () => Promise<unknown>;
|
||||
}
|
||||
const reply = (body: unknown, status = 200): FakeResponse => ({
|
||||
ok: status >= 200 && status < 300,
|
||||
status,
|
||||
json: async () => body,
|
||||
});
|
||||
|
||||
function loadPanel(
|
||||
options: {
|
||||
confirm?: (message: string) => boolean;
|
||||
fetch?: (url: string, init?: { method?: string }) => Promise<FakeResponse>;
|
||||
session?: Record<string, unknown>;
|
||||
} = {}
|
||||
) {
|
||||
const CodemanApp = function CodemanApp(this: unknown) {} as unknown as new () => Record<string, any>;
|
||||
const context = vm.createContext({
|
||||
CodemanApp,
|
||||
console,
|
||||
localStorage: { getItem: () => null, setItem: () => {}, removeItem: () => {} },
|
||||
document: { getElementById: () => null, addEventListener: vi.fn() },
|
||||
window: { addEventListener: vi.fn() },
|
||||
// The search debounce, collapsed so the re-run lands on the next turn.
|
||||
setTimeout: (fn: () => void) => setTimeout(fn, 0),
|
||||
clearTimeout,
|
||||
CodemanBase: { url: (p: string) => p },
|
||||
confirm: vi.fn(options.confirm ?? (() => true)),
|
||||
fetch: vi.fn(options.fetch ?? (async () => reply({ success: true, data: {} }))),
|
||||
});
|
||||
vm.runInContext(ESCAPE_SRC, context, { filename: 'constants.js (escapeHtml)' });
|
||||
vm.runInContext(PANELS, context, { filename: 'panels-ui.js' });
|
||||
|
||||
const app = new CodemanApp();
|
||||
app.$ = (id: string) => document.getElementById(id);
|
||||
app.activeSessionId = SESSION;
|
||||
app.sessions = new Map([[SESSION, options.session ?? { id: SESSION }]]);
|
||||
app.fileBrowserExpandedDirs = new Set(['src']);
|
||||
app.fileBrowserShowHidden = false;
|
||||
app.getFileIcon = () => '';
|
||||
app.formatFileSize = () => '';
|
||||
app.openFilePreview = vi.fn();
|
||||
app.showToast = vi.fn();
|
||||
// The sentinel the old inline handler reached: `app.deleteFileFromBrowser(...)` ran in the page.
|
||||
win.app = app;
|
||||
win.alert = vi.fn();
|
||||
return { app, context: context as Record<string, any> };
|
||||
}
|
||||
|
||||
const fileNode = (name: string) => ({ name, path: `src/${name}`, type: 'file', size: 1, extension: 'txt' });
|
||||
const treeEnvelope = (names: string[]) => ({
|
||||
success: true,
|
||||
data: {
|
||||
root: '/repo',
|
||||
tree: [{ name: 'src', path: 'src', type: 'directory', children: names.map(fileNode) }],
|
||||
totalFiles: names.length,
|
||||
totalDirectories: 1,
|
||||
truncated: false,
|
||||
},
|
||||
});
|
||||
const searchEnvelope = (names: string[]) => ({
|
||||
success: true,
|
||||
data: { mode: 'search', matches: names.map(fileNode), matchCount: names.length, truncated: false },
|
||||
});
|
||||
|
||||
const rowFor = (path: string) =>
|
||||
[...treeEl().querySelectorAll<HTMLElement>('.file-tree-item')].find((row) => row.dataset.path === path);
|
||||
const deleteButtonFor = (path: string) => rowFor(path)?.querySelector<HTMLButtonElement>('.file-tree-delete');
|
||||
|
||||
function renderTree(app: Record<string, any>, names: string[]) {
|
||||
app.fileBrowserData = app._validateFileBrowserTreeEnvelope(treeEnvelope(names));
|
||||
app.renderFileBrowserTree(SESSION);
|
||||
}
|
||||
|
||||
function renderSearch(app: Record<string, any>, names: string[]) {
|
||||
app._renderFileBrowserSearchResults(names.map(fileNode), SESSION, { matchCount: names.length, truncated: false });
|
||||
}
|
||||
|
||||
describe('Files panel delete button: no inline handler, the exact path', () => {
|
||||
it.each([
|
||||
['tree', renderTree],
|
||||
['search results', renderSearch],
|
||||
])('%s: a click deletes exactly the hostile path and runs nothing', (_label, render) => {
|
||||
const { app } = loadPanel();
|
||||
app.deleteFileFromBrowser = vi.fn();
|
||||
render(app, HOSTILE);
|
||||
|
||||
expect(treeEl().innerHTML).not.toContain('decodeURIComponent');
|
||||
for (const name of HOSTILE) {
|
||||
const button = deleteButtonFor(`src/${name}`);
|
||||
expect(button, name).toBeTruthy();
|
||||
expect(button!.getAttribute('onclick')).toBeNull();
|
||||
expect(button!.type).toBe('button');
|
||||
|
||||
button!.click();
|
||||
|
||||
expect(app.deleteFileFromBrowser).toHaveBeenLastCalledWith(SESSION, `src/${name}`);
|
||||
}
|
||||
expect(app.deleteFileFromBrowser).toHaveBeenCalledTimes(HOSTILE.length);
|
||||
expect(win.alert).not.toHaveBeenCalled();
|
||||
// The click stays on the button: the row behind it does not open the file.
|
||||
expect(app.openFilePreview).not.toHaveBeenCalled();
|
||||
// The row itself still opens it.
|
||||
rowFor(`src/${HOSTILE[0]}`)!.click();
|
||||
expect(app.openFilePreview).toHaveBeenCalledWith(`src/${HOSTILE[0]}`, SESSION);
|
||||
});
|
||||
|
||||
it('labels the button with the escaped name, as text', () => {
|
||||
const { app } = loadPanel();
|
||||
renderTree(app, ['a "b" <i>c</i> & d.txt']);
|
||||
const button = deleteButtonFor('src/a "b" <i>c</i> & d.txt')!;
|
||||
expect(button.getAttribute('aria-label')).toBe('Delete a "b" <i>c</i> & d.txt');
|
||||
expect(rowFor('src/a "b" <i>c</i> & d.txt')!.querySelector('i')).toBeNull();
|
||||
});
|
||||
|
||||
it('offers no delete button on a folder row', () => {
|
||||
const { app } = loadPanel();
|
||||
renderTree(app, ['a.txt']);
|
||||
expect(rowFor('src')!.querySelector('.file-tree-delete')).toBeNull();
|
||||
expect(deleteButtonFor('src/a.txt')).toBeTruthy();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['a remote (SSH) session', { id: SESSION, remote: { host: 'h' } }],
|
||||
['an adopted session', { id: SESSION, adopt: { kind: 'local' } }],
|
||||
])('offers none for %s, which the route refuses', (_label, session) => {
|
||||
const { app } = loadPanel({ session });
|
||||
renderTree(app, ['a.txt']);
|
||||
expect(rowFor('src/a.txt')).toBeTruthy();
|
||||
expect(deleteButtonFor('src/a.txt')).toBeNull();
|
||||
renderSearch(app, ['a.txt']);
|
||||
expect(rowFor('src/a.txt')).toBeTruthy();
|
||||
expect(deleteButtonFor('src/a.txt')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteFileFromBrowser', () => {
|
||||
it('asks first and sends nothing when the answer is no', async () => {
|
||||
const { app, context } = loadPanel({ confirm: () => false });
|
||||
await app.deleteFileFromBrowser(SESSION, "src/it's.txt");
|
||||
expect(context.confirm).toHaveBeenCalledWith("Delete the file src/it's.txt? This cannot be undone.");
|
||||
expect(context.fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('sends one DELETE with the encoded path, then reloads keeping the query', async () => {
|
||||
const { app, context } = loadPanel({ fetch: async () => reply({ success: true, data: { path: 'x' } }) });
|
||||
app._reloadFileBrowserKeepingQuery = vi.fn(async () => {});
|
||||
const path = "src/x'-alert(1)-' & #?.txt";
|
||||
|
||||
await app.deleteFileFromBrowser(SESSION, path);
|
||||
|
||||
expect(context.fetch).toHaveBeenCalledTimes(1);
|
||||
expect(context.fetch).toHaveBeenCalledWith(`/api/sessions/${SESSION}/files?path=${encodeURIComponent(path)}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
expect(app.showToast).toHaveBeenCalledWith(`File deleted: ${path}`, 'success');
|
||||
expect(app._reloadFileBrowserKeepingQuery).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('shows the server reason and reloads nothing when the delete is refused', async () => {
|
||||
const { app } = loadPanel({
|
||||
fetch: async () => reply({ success: false, error: 'Symbolic links are not deleted from the Files panel' }, 400),
|
||||
});
|
||||
app._reloadFileBrowserKeepingQuery = vi.fn(async () => {});
|
||||
|
||||
await app.deleteFileFromBrowser(SESSION, 'AGENTS.md');
|
||||
|
||||
expect(app.showToast).toHaveBeenCalledWith(
|
||||
'Delete failed: Symbolic links are not deleted from the Files panel',
|
||||
'error'
|
||||
);
|
||||
expect(app._reloadFileBrowserKeepingQuery).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('removes the row from the search results it was deleted from', async () => {
|
||||
let files = ['delete-me.txt', 'keep-me.txt', 'other.md'];
|
||||
const { app, context } = loadPanel({
|
||||
fetch: async (url, init) => {
|
||||
const query = new URL(url, 'http://localhost');
|
||||
if (init?.method === 'DELETE') {
|
||||
const gone = query.searchParams.get('path')!.replace(/^src\//, '');
|
||||
files = files.filter((name) => name !== gone);
|
||||
return reply({ success: true, data: { path: query.searchParams.get('path') } });
|
||||
}
|
||||
const q = query.searchParams.get('q');
|
||||
return reply(q ? searchEnvelope(files.filter((name) => name.includes(q))) : treeEnvelope(files));
|
||||
},
|
||||
});
|
||||
await app.loadFileBrowser(SESSION);
|
||||
(document.getElementById('fileBrowserSearch') as HTMLInputElement).value = 'me';
|
||||
app.filterFileBrowser('me');
|
||||
await vi.waitFor(() => expect(rowFor('src/delete-me.txt')).toBeTruthy());
|
||||
expect(rowFor('src/other.md')).toBeUndefined();
|
||||
|
||||
deleteButtonFor('src/delete-me.txt')!.click();
|
||||
|
||||
await vi.waitFor(() => expect(rowFor('src/delete-me.txt')).toBeUndefined());
|
||||
expect(rowFor('src/keep-me.txt')).toBeTruthy();
|
||||
// Still the search view, not the tree behind it.
|
||||
expect(rowFor('src/other.md')).toBeUndefined();
|
||||
expect(document.getElementById('fileBrowserStatus')!.textContent).toBe('1 match');
|
||||
const methods = context.fetch.mock.calls.map(([url, init]: [string, { method?: string }?]) =>
|
||||
init?.method === 'DELETE' ? 'DELETE' : url.includes('&q=') ? 'search' : 'tree'
|
||||
);
|
||||
expect(methods).toEqual(['tree', 'search', 'DELETE', 'tree', 'search']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Files panel delete button: touch screens', () => {
|
||||
it('keeps the row buttons visible where there is no hover', () => {
|
||||
const blocks = [...STYLES.matchAll(/@media \(hover: none\) \{([^{}]*\{[^{}]*\})\s*\}/g)].map((m) => m[1]);
|
||||
expect(
|
||||
blocks.some((block) => /\.file-tree-download,\s*\.file-tree-delete\s*\{\s*display: inline;/.test(block))
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
Vendored
+95
-37
@@ -18,6 +18,7 @@
|
||||
* | `GET /api/ps` | the model once a chat loaded it (`size`, `size_vram`, `context_length`) |
|
||||
* | `POST /api/pull` | NDJSON progress, see `FakePullSwitches` |
|
||||
* | `DELETE /api/delete` | 200, or 404 for a model that is not installed |
|
||||
* | | (the model, or one of `otherModels`) |
|
||||
* | `GET /v1/models` | the model's id, or `data: null` when none is installed (as the real one) |
|
||||
* | `POST /v1/chat/completions` | a plain answer, or a tool call when the request carries `tools` |
|
||||
* | `GET /download/ollama-darwin.tgz` | a small real `.tgz` with Range support, see `FakeArchiveSwitches` |
|
||||
@@ -220,6 +221,14 @@ export interface FakeOllamaOptions {
|
||||
spawn?: Partial<FakeSpawnSwitches>;
|
||||
/** The files of the fake runtime archive, name to content. Default: one executable `ollama` script. */
|
||||
archiveFiles?: Record<string, string>;
|
||||
/**
|
||||
* More models the registry has beside `model`: each can be pulled (a short stream that
|
||||
* succeeds), listed, loaded, checked and deleted on its own. The pull, chat and tool
|
||||
* switches apply to them too, except the pull's failures and shapes, which are `model`'s.
|
||||
*/
|
||||
otherModels?: FakeOllamaModel[];
|
||||
/** The ids of `otherModels` that are on disk from the start. */
|
||||
othersInstalled?: string[];
|
||||
}
|
||||
|
||||
export interface CapturedRequest {
|
||||
@@ -304,6 +313,10 @@ export interface FakeOllama {
|
||||
archiveRequests: number;
|
||||
/** Every child `spawn` made, oldest first. */
|
||||
children: FakeRuntimeChild[];
|
||||
/** The ids of `otherModels` that are on disk: pulled, or installed from the start. */
|
||||
others: string[];
|
||||
/** The id of the model the last chat loaded. The runtime holds one model at a time. */
|
||||
loadedId?: string;
|
||||
};
|
||||
/** The fake runtime archive: a real gzipped tar. */
|
||||
readonly archive: { readonly body: Buffer; readonly size: number; readonly sha256: string };
|
||||
@@ -436,7 +449,26 @@ export function createFakeOllama(options: FakeOllamaOptions = {}): FakeOllama {
|
||||
pulls: 0,
|
||||
archiveRequests: 0,
|
||||
children: [],
|
||||
others: [...(options.othersInstalled ?? [])],
|
||||
};
|
||||
const otherModels = options.otherModels ?? [];
|
||||
/** One of `otherModels` by its id, whether it is on disk or not. */
|
||||
const otherModel = (id: unknown): FakeOllamaModel | undefined => otherModels.find((other) => other.id === id);
|
||||
/** The model `id` names when it is on disk: `model` or one of the others. */
|
||||
const onDisk = (id: unknown): FakeOllamaModel | undefined => {
|
||||
if (id === model.id) return state.installed ? model : undefined;
|
||||
const other = otherModel(id);
|
||||
return other && state.others.includes(other.id) ? other : undefined;
|
||||
};
|
||||
const tagRow = (entry: FakeOllamaModel): Record<string, unknown> => ({
|
||||
name: entry.id,
|
||||
model: entry.id,
|
||||
modified_at: '2026-11-06T15:10:02.465346347+01:00',
|
||||
size: entry.bytes,
|
||||
digest: entry.digest,
|
||||
details: { ...modelDetails(), format: entry.format, context_length: 262144, runner: entry.runner },
|
||||
capabilities: entry.capabilities,
|
||||
});
|
||||
const requests: CapturedRequest[] = [];
|
||||
const sockets = new Set<Socket>();
|
||||
let server: Server | undefined;
|
||||
@@ -475,6 +507,18 @@ export function createFakeOllama(options: FakeOllamaOptions = {}): FakeOllama {
|
||||
};
|
||||
|
||||
if (!(await line({ status: 'pulling manifest' }))) return;
|
||||
// One of the other models: a short stream that succeeds.
|
||||
const other = otherModel(askedId);
|
||||
if (other && pull.fail !== 'not-found') {
|
||||
const layer = { status: 'pulling model', digest: 'sha256:other', total: other.bytes };
|
||||
if (!(await line({ ...layer, completed: 0 }))) return;
|
||||
if (!(await line({ ...layer, completed: other.bytes }))) return;
|
||||
if (!(await line({ status: 'writing manifest' }))) return;
|
||||
if (!state.others.includes(other.id)) state.others.push(other.id);
|
||||
await line({ status: 'success' });
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
// Not captured on a Mac: the wording of the runtime for a tag the registry does not have.
|
||||
if (pull.fail === 'not-found' || askedId !== model.id) {
|
||||
await line({ error: 'pull model manifest: file does not exist' });
|
||||
@@ -528,7 +572,8 @@ export function createFakeOllama(options: FakeOllamaOptions = {}): FakeOllama {
|
||||
|
||||
function handleChat(res: ServerResponse, body: unknown): void {
|
||||
const request = (body as { model?: unknown; tools?: unknown } | undefined) ?? {};
|
||||
if (!state.installed || request.model !== model.id) {
|
||||
const served = onDisk(request.model);
|
||||
if (!served) {
|
||||
sendJson(res, 404, {
|
||||
error: {
|
||||
message: `model '${String(request.model)}' not found`,
|
||||
@@ -544,7 +589,7 @@ export function createFakeOllama(options: FakeOllamaOptions = {}): FakeOllama {
|
||||
id: `chatcmpl-${chatCounter++}`,
|
||||
object: 'chat.completion',
|
||||
created: 1793968038,
|
||||
model: model.id,
|
||||
model: served.id,
|
||||
system_fingerprint: 'fp_ollama',
|
||||
choices: [{ index: 0, message: { role: 'assistant', ...message }, finish_reason: finishReason }],
|
||||
usage: {
|
||||
@@ -563,12 +608,18 @@ export function createFakeOllama(options: FakeOllamaOptions = {}): FakeOllama {
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (switches.ps.loaded !== false) state.loaded = true;
|
||||
if (switches.ps.loaded !== false) {
|
||||
state.loaded = true;
|
||||
state.loadedId = served.id;
|
||||
}
|
||||
sendJson(res, 200, envelope({ content: 'pong' }, 'stop'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (switches.ps.loaded !== false) state.loaded = true;
|
||||
if (switches.ps.loaded !== false) {
|
||||
state.loaded = true;
|
||||
state.loadedId = served.id;
|
||||
}
|
||||
const fn = (tools[0] as { function?: { name?: unknown; parameters?: { properties?: unknown } } }).function ?? {};
|
||||
const name = typeof fn.name === 'string' ? fn.name : 'tool';
|
||||
const properties = (fn.parameters?.properties ?? {}) as Record<string, unknown>;
|
||||
@@ -648,39 +699,31 @@ export function createFakeOllama(options: FakeOllamaOptions = {}): FakeOllama {
|
||||
}
|
||||
if (path === '/api/version' && method === 'GET') return sendJson(res, 200, { version });
|
||||
if (path === '/api/tags' && method === 'GET') {
|
||||
const models = state.installed
|
||||
? [
|
||||
{
|
||||
name: model.id,
|
||||
model: model.id,
|
||||
modified_at: '2026-11-06T15:10:02.465346347+01:00',
|
||||
size: model.bytes,
|
||||
digest: model.digest,
|
||||
details: { ...modelDetails(), context_length: 262144, runner: model.runner },
|
||||
capabilities: model.capabilities,
|
||||
},
|
||||
]
|
||||
: [];
|
||||
const models = [
|
||||
...(state.installed ? [tagRow(model)] : []),
|
||||
...otherModels.filter((other) => state.others.includes(other.id)).map(tagRow),
|
||||
];
|
||||
return sendJson(res, 200, { models });
|
||||
}
|
||||
if (path === '/api/ps' && method === 'GET') {
|
||||
const size = switches.ps.size ?? model.bytes + 100_000_000;
|
||||
const models =
|
||||
state.installed && state.loaded && switches.ps.loaded !== false
|
||||
? [
|
||||
{
|
||||
name: model.id,
|
||||
model: model.id,
|
||||
size,
|
||||
digest: model.digest,
|
||||
details: modelDetails(),
|
||||
expires_at: '2026-11-06T15:20:09.488045+01:00',
|
||||
size_vram: switches.ps.sizeVram ?? size,
|
||||
context_length: switches.ps.contextLength ?? contextLength,
|
||||
runner: model.runner,
|
||||
},
|
||||
]
|
||||
: [];
|
||||
// The runtime holds ONE model: the one the last chat loaded.
|
||||
const held = state.loaded && switches.ps.loaded !== false ? onDisk(state.loadedId ?? model.id) : undefined;
|
||||
const size = switches.ps.size ?? (held ?? model).bytes + 100_000_000;
|
||||
const models = held
|
||||
? [
|
||||
{
|
||||
name: held.id,
|
||||
model: held.id,
|
||||
size,
|
||||
digest: held.digest,
|
||||
details: modelDetails(),
|
||||
expires_at: '2026-11-06T15:20:09.488045+01:00',
|
||||
size_vram: switches.ps.sizeVram ?? size,
|
||||
context_length: switches.ps.contextLength ?? contextLength,
|
||||
runner: held.runner,
|
||||
},
|
||||
]
|
||||
: [];
|
||||
return sendJson(res, 200, { models });
|
||||
}
|
||||
if (path === '/api/pull' && method === 'POST') return handlePull(res, body);
|
||||
@@ -688,20 +731,35 @@ export function createFakeOllama(options: FakeOllamaOptions = {}): FakeOllama {
|
||||
// Not captured: the answers of a delete (200 with no body, 404 with an error text).
|
||||
const asked = (body as { model?: unknown; name?: unknown } | undefined) ?? {};
|
||||
const askedId = typeof asked.model === 'string' ? asked.model : asked.name;
|
||||
const other = otherModel(askedId);
|
||||
if (other && state.others.includes(other.id)) {
|
||||
state.others = state.others.filter((id) => id !== other.id);
|
||||
if (state.loadedId === other.id) {
|
||||
state.loaded = false;
|
||||
state.loadedId = undefined;
|
||||
}
|
||||
res.writeHead(200);
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
if (!state.installed || askedId !== model.id) {
|
||||
return sendJson(res, 404, { error: `model '${String(askedId)}' not found` });
|
||||
}
|
||||
state.installed = false;
|
||||
state.loaded = false;
|
||||
state.loadedId = undefined;
|
||||
state.completedBytes = 0;
|
||||
res.writeHead(200);
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
if (path === '/v1/models' && method === 'GET') {
|
||||
const data = state.installed
|
||||
? [{ id: model.id, object: 'model', created: 1793967749, owned_by: 'library' }]
|
||||
: null;
|
||||
const ids = [
|
||||
...(state.installed ? [model.id] : []),
|
||||
...otherModels.filter((other) => state.others.includes(other.id)).map((other) => other.id),
|
||||
];
|
||||
const data =
|
||||
ids.length > 0 ? ids.map((id) => ({ id, object: 'model', created: 1793967749, owned_by: 'library' })) : null;
|
||||
return sendJson(res, 200, { object: 'list', data });
|
||||
}
|
||||
if (path === '/v1/chat/completions' && method === 'POST') return handleChat(res, body);
|
||||
|
||||
@@ -1,141 +0,0 @@
|
||||
// Port: none (the real home-screen modules in a vm context with a fake
|
||||
// matchMedia; no browser, no server).
|
||||
//
|
||||
// #702: folding a phone on the home screen must swap the home surface even when
|
||||
// the page loaded wide. The phone overview's breakpoint listener used to be
|
||||
// wired from its first show, which a wide-loaded page never reaches. Both home
|
||||
// screens' listeners are now installed once at startup and self-gate on a home
|
||||
// screen being up. The real resize walk lives in
|
||||
// test/home-breakpoint-swap.browser.test.ts (browser suite, not in the gate);
|
||||
// this file keeps the wiring itself under `npm test`.
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import vm from 'node:vm';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
const PUBLIC = resolve(import.meta.dirname, '../src/web/public');
|
||||
|
||||
type Listener = () => void;
|
||||
|
||||
function loadHomeApp(innerWidth = 1440) {
|
||||
const listeners = new Map<string, Listener[]>();
|
||||
const elements: Record<string, any> = {
|
||||
welcomeOverlay: { classList: new Set<string>(), hidden: false },
|
||||
mobileOverview: { classList: new Set<string>(), hidden: true },
|
||||
homeSessions: { hidden: true },
|
||||
};
|
||||
for (const el of Object.values(elements)) {
|
||||
if (el.classList) {
|
||||
const set = el.classList as Set<string>;
|
||||
el.classList = {
|
||||
contains: (c: string) => set.has(c),
|
||||
add: (c: string) => set.add(c),
|
||||
remove: (c: string) => set.delete(c),
|
||||
};
|
||||
}
|
||||
}
|
||||
const timers: Array<() => void> = [];
|
||||
const window: any = {
|
||||
innerWidth,
|
||||
matchMedia: (query: string) => ({
|
||||
matches: false,
|
||||
addEventListener: (_type: string, fn: Listener) => {
|
||||
listeners.set(query, [...(listeners.get(query) || []), fn]);
|
||||
},
|
||||
}),
|
||||
};
|
||||
const CodemanApp = function CodemanApp(this: any) {};
|
||||
const context = vm.createContext({
|
||||
CodemanApp,
|
||||
console,
|
||||
window,
|
||||
document: {
|
||||
documentElement: { getAttribute: () => null },
|
||||
getElementById: (id: string) => elements[id] || null,
|
||||
createElement: () => ({ dataset: {}, style: {}, appendChild() {}, setAttribute() {} }),
|
||||
},
|
||||
MobileDetection: { getDeviceType: () => (window.innerWidth < 600 ? 'mobile' : 'desktop') },
|
||||
});
|
||||
for (const file of ['constants.js', 'mobile-overview.js', 'home-sessions.js']) {
|
||||
vm.runInContext(readFileSync(resolve(PUBLIC, file), 'utf8'), context, { filename: file });
|
||||
}
|
||||
const app = new (CodemanApp as any)();
|
||||
app.loadAppSettingsFromStorage = () => ({});
|
||||
app.activeSessionId = null;
|
||||
app.showWelcomeCalls = 0;
|
||||
app.showWelcome = () => app.showWelcomeCalls++;
|
||||
app.renderHomeSessionsCalls = 0;
|
||||
app.renderHomeSessions = () => {
|
||||
app.renderHomeSessionsCalls++;
|
||||
elements.homeSessions.hidden = false;
|
||||
};
|
||||
app._debouncedCall = (_key: string, fn: () => void) => timers.push(fn);
|
||||
const fire = (query: string) => (listeners.get(query) || []).forEach((fn) => fn());
|
||||
const flushTimers = () => timers.splice(0).forEach((fn) => fn());
|
||||
return { app, window, elements, listeners, fire, flushTimers };
|
||||
}
|
||||
|
||||
const PHONE_QUERY = '(max-width: 599px)';
|
||||
const RAIL_QUERY = '(min-width: 1180px)';
|
||||
|
||||
describe('home-screen breakpoint listeners (#702)', () => {
|
||||
it('installs each listener once, with nothing shown yet', () => {
|
||||
const { app, listeners } = loadHomeApp();
|
||||
app._wireMobileOverviewBreakpoint();
|
||||
app._wireHomeSessionsBreakpoint();
|
||||
// The lazy wiring from a first show must not add a second copy.
|
||||
app._wireMobileOverviewBreakpoint();
|
||||
app._wireHomeSessionsBreakpoint();
|
||||
expect(listeners.get(PHONE_QUERY)).toHaveLength(1);
|
||||
expect(listeners.get(RAIL_QUERY)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('a fold on a wide-loaded welcome screen re-decides the surface', () => {
|
||||
const { app, window, elements, fire } = loadHomeApp(1440);
|
||||
app._wireMobileOverviewBreakpoint();
|
||||
elements.welcomeOverlay.classList.add('visible');
|
||||
window.innerWidth = 390;
|
||||
fire(PHONE_QUERY);
|
||||
expect(app.showWelcomeCalls).toBe(1);
|
||||
});
|
||||
|
||||
it('an unfold on the phone overview re-decides the surface', () => {
|
||||
const { app, window, elements, fire } = loadHomeApp(390);
|
||||
app._wireMobileOverviewBreakpoint();
|
||||
elements.mobileOverview.classList.add('visible');
|
||||
window.innerWidth = 820;
|
||||
fire(PHONE_QUERY);
|
||||
expect(app.showWelcomeCalls).toBe(1);
|
||||
});
|
||||
|
||||
it('stays out of the way inside a session and before any home screen is up', () => {
|
||||
const { app, elements, fire } = loadHomeApp();
|
||||
app._wireMobileOverviewBreakpoint();
|
||||
fire(PHONE_QUERY); // page still loading: no home surface yet
|
||||
elements.welcomeOverlay.classList.add('visible');
|
||||
app.activeSessionId = 'abc';
|
||||
fire(PHONE_QUERY);
|
||||
expect(app.showWelcomeCalls).toBe(0);
|
||||
});
|
||||
|
||||
it('a rail render queued before a fold does not bring the hidden rail back', () => {
|
||||
const { app, window, elements, flushTimers } = loadHomeApp(1440);
|
||||
elements.welcomeOverlay.classList.add('visible');
|
||||
elements.homeSessions.hidden = false;
|
||||
app._refreshHomeSessionsIfVisible(); // queued while the rail is still up
|
||||
window.innerWidth = 390; // the fold
|
||||
elements.homeSessions.hidden = true; // showWelcome()'s phone path hid it
|
||||
flushTimers();
|
||||
expect(app.renderHomeSessionsCalls).toBe(0);
|
||||
expect(elements.homeSessions.hidden).toBe(true);
|
||||
});
|
||||
|
||||
it('is wired from init(), not from the first show', () => {
|
||||
const appJs = readFileSync(resolve(PUBLIC, 'app.js'), 'utf8');
|
||||
const start = appJs.indexOf('\n init() {');
|
||||
const end = appJs.indexOf('\n }\n', start);
|
||||
const init = appJs.slice(start, end);
|
||||
expect(init).toContain('this._wireMobileOverviewBreakpoint?.()');
|
||||
expect(init).toContain('this._wireHomeSessionsBreakpoint?.()');
|
||||
});
|
||||
});
|
||||
@@ -1,151 +0,0 @@
|
||||
/**
|
||||
* @fileoverview Folding or unfolding a phone on the home screen swaps the home
|
||||
* surface in place, whichever width the page loaded at (#702).
|
||||
*
|
||||
* Both breakpoint listeners (the phone overview's `(max-width: 599px)` and the
|
||||
* desktop rail's `(min-width: 1180px)`) used to be wired from the first show of
|
||||
* their own surface. A page that loaded wide never showed the phone overview,
|
||||
* so folding it to phone width left the welcome screen up until something
|
||||
* called `showWelcome()` again. They are now installed once at startup.
|
||||
*
|
||||
* Real server, real Chromium, one page per direction, resized across 599/600
|
||||
* and 767/768 in both directions with no navigation, plus a height-only resize
|
||||
* (the on-screen keyboard) that must not re-decide the surface.
|
||||
*/
|
||||
import { mkdtempSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { chromium, type Browser, type BrowserContext, type Page } from 'playwright';
|
||||
import { WebServer } from '../src/web/server.js';
|
||||
|
||||
type Surface = { welcome: boolean; overview: boolean; rail: boolean };
|
||||
|
||||
describe('home surface follows a fold or unfold without a reload', () => {
|
||||
let server: WebServer;
|
||||
let browser: Browser;
|
||||
let workDir: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
server = new WebServer(0, false, true);
|
||||
await server.start();
|
||||
// One session, so the desktop rail has a row to show (it hides when empty).
|
||||
workDir = mkdtempSync(join(tmpdir(), 'home-breakpoint-'));
|
||||
const res = await fetch(`http://localhost:${server.boundPort}/api/sessions`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ workingDir: workDir }),
|
||||
});
|
||||
expect(res.ok).toBe(true);
|
||||
browser = await chromium.launch({ headless: true });
|
||||
}, 90000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (browser) await browser.close();
|
||||
if (server) await server.stop();
|
||||
if (workDir) rmSync(workDir, { recursive: true, force: true });
|
||||
}, 60000);
|
||||
|
||||
async function open(width: number, height: number): Promise<[BrowserContext, Page]> {
|
||||
const context = await browser.newContext({ viewport: { width, height }, deviceScaleFactor: 1 });
|
||||
const page = await context.newPage();
|
||||
await page.goto(`http://localhost:${server.boundPort}`, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForFunction(() => (window as any).app?.terminal, null, { timeout: 30000 });
|
||||
// The load selects the one session; Home is where a fold has to swap surfaces.
|
||||
await page.waitForFunction(() => (window as any).app?.sessions?.size > 0, null, { timeout: 30000 });
|
||||
await page.evaluate(() => (window as any).app.goHome());
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
document.getElementById('welcomeOverlay')?.classList.contains('visible') ||
|
||||
document.getElementById('mobileOverview')?.classList.contains('visible'),
|
||||
null,
|
||||
{ timeout: 30000 }
|
||||
);
|
||||
return [context, page];
|
||||
}
|
||||
|
||||
const surface = (page: Page): Promise<Surface> =>
|
||||
page.evaluate(() => {
|
||||
const overview = document.getElementById('mobileOverview')!;
|
||||
const rail = document.getElementById('homeSessions')!;
|
||||
return {
|
||||
welcome: document.getElementById('welcomeOverlay')!.classList.contains('visible'),
|
||||
overview: !overview.hidden && overview.classList.contains('visible'),
|
||||
rail: !rail.hidden,
|
||||
};
|
||||
});
|
||||
|
||||
/** Resize, then wait until the surface settles on what that width should show. */
|
||||
async function resizeTo(page: Page, width: number, height: number, expected: Surface) {
|
||||
await page.setViewportSize({ width, height });
|
||||
await expect
|
||||
.poll(() => surface(page), { timeout: 5000, message: `surface at ${width}x${height}` })
|
||||
.toEqual(expected);
|
||||
}
|
||||
|
||||
const PHONE: Surface = { welcome: false, overview: true, rail: false };
|
||||
const TABLET: Surface = { welcome: true, overview: false, rail: false };
|
||||
const DESKTOP: Surface = { welcome: true, overview: false, rail: true };
|
||||
|
||||
it('loaded wide: folding to phone width shows the phone overview, unfolding brings the welcome back', async () => {
|
||||
const [context, page] = await open(1440, 900);
|
||||
try {
|
||||
expect(await surface(page)).toEqual(DESKTOP);
|
||||
await resizeTo(page, 768, 900, TABLET);
|
||||
await resizeTo(page, 767, 900, TABLET);
|
||||
await resizeTo(page, 600, 900, TABLET);
|
||||
await resizeTo(page, 599, 900, PHONE);
|
||||
await resizeTo(page, 390, 844, PHONE);
|
||||
await resizeTo(page, 600, 900, TABLET);
|
||||
await resizeTo(page, 767, 900, TABLET);
|
||||
await resizeTo(page, 768, 900, TABLET);
|
||||
await resizeTo(page, 1440, 900, DESKTOP);
|
||||
// A straight fold, the way a real device changes posture, in one step.
|
||||
await resizeTo(page, 390, 844, PHONE);
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
}, 60000);
|
||||
|
||||
it('loaded narrow: unfolding to desktop width shows the welcome and the rail, folding brings the overview back', async () => {
|
||||
const [context, page] = await open(390, 844);
|
||||
try {
|
||||
expect(await surface(page)).toEqual(PHONE);
|
||||
await resizeTo(page, 599, 844, PHONE);
|
||||
await resizeTo(page, 600, 844, TABLET);
|
||||
await resizeTo(page, 767, 844, TABLET);
|
||||
await resizeTo(page, 768, 844, TABLET);
|
||||
await resizeTo(page, 1440, 900, DESKTOP);
|
||||
await resizeTo(page, 768, 900, TABLET);
|
||||
await resizeTo(page, 767, 900, TABLET);
|
||||
await resizeTo(page, 600, 900, TABLET);
|
||||
await resizeTo(page, 599, 900, PHONE);
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
}, 60000);
|
||||
|
||||
it('a height-only resize (the on-screen keyboard) does not re-decide the home surface', async () => {
|
||||
const [context, page] = await open(1440, 900);
|
||||
try {
|
||||
await resizeTo(page, 390, 844, PHONE);
|
||||
await page.evaluate(() => {
|
||||
const app = (window as any).app;
|
||||
app.__showWelcomeCalls = 0;
|
||||
const original = app.showWelcome;
|
||||
app.showWelcome = function (...args: unknown[]) {
|
||||
app.__showWelcomeCalls++;
|
||||
return original.apply(this, args);
|
||||
};
|
||||
});
|
||||
await page.setViewportSize({ width: 390, height: 480 });
|
||||
await page.waitForTimeout(400);
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await page.waitForTimeout(400);
|
||||
expect(await surface(page)).toEqual(PHONE);
|
||||
expect(await page.evaluate(() => (window as any).app.__showWelcomeCalls)).toBe(0);
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
}, 60000);
|
||||
});
|
||||
@@ -345,7 +345,7 @@ describe('the install from the first GET to Remove, real modules over a fake run
|
||||
endpointId: 'local-ollama',
|
||||
folder: world.paths.root,
|
||||
loaded: { context: pick.context, onGpu: true },
|
||||
actions: ['start-session', 'stop', 'remove'],
|
||||
actions: ['start-session', 'stop', 'install', 'remove'],
|
||||
runtime: { installed: true, downloadBytes: 0, downloadGb: formatGb(0) },
|
||||
});
|
||||
|
||||
@@ -682,6 +682,154 @@ describe('the install from the first GET to Remove, real modules over a fake run
|
||||
}, 60_000);
|
||||
});
|
||||
|
||||
describe('the library: a second model, the switch back, and the delete of one model', () => {
|
||||
it('installs A, adds B beside it, switches back to A, and deletes B, with one model in use throughout', async () => {
|
||||
const { app, get, settled, install: clickInstall } = await routes();
|
||||
const hostsModels = async () =>
|
||||
(await readCustomModelHosts(getDataDir())).find((host) => host.id === 'local-ollama')?.models;
|
||||
const post = (url: string, payload: unknown) => app.inject({ method: 'POST', url, payload: payload as object });
|
||||
const deleteModel = (modelId: string) =>
|
||||
app.inject({ method: 'DELETE', url: '/api/local-model/models', payload: { modelId } });
|
||||
|
||||
// ---- A: the first install ----
|
||||
world.agentInstalled = true;
|
||||
const first = await get();
|
||||
const a = first.recommendation.pick!;
|
||||
const bRow = first.recommendation.alternatives[0];
|
||||
expect(bRow).toBeDefined();
|
||||
expect(bRow.modelId).not.toBe(a.modelId);
|
||||
const B_DIGEST = 'b'.repeat(64);
|
||||
world.runtime = createFakeOllama({
|
||||
model: { id: a.modelId, bytes: a.downloadBytes },
|
||||
contextLength: a.context,
|
||||
otherModels: [
|
||||
{
|
||||
id: bRow.modelId,
|
||||
bytes: bRow.downloadBytes,
|
||||
digest: B_DIGEST,
|
||||
format: 'gguf',
|
||||
runner: 'ggml',
|
||||
quantization: 'Q4_K_M',
|
||||
capabilities: ['completion', 'tools'],
|
||||
},
|
||||
],
|
||||
});
|
||||
const runtime = world.runtime;
|
||||
expect((await clickInstall(a)).statusCode).toBe(200);
|
||||
expect((await settled()).state).toBe('installed');
|
||||
|
||||
let status = await get();
|
||||
expect(status.install.library).toEqual([
|
||||
{
|
||||
id: a.modelId,
|
||||
label: a.modelLabel,
|
||||
context: a.context,
|
||||
sizeBytes: a.downloadBytes,
|
||||
sizeGb: formatGb(a.downloadBytes),
|
||||
active: true,
|
||||
verified: true,
|
||||
status: 'ready',
|
||||
actions: [],
|
||||
},
|
||||
]);
|
||||
// The store of the fake runtime writes no blobs: measured, and empty.
|
||||
expect(status.install.store).toEqual({ bytes: 0, gb: '0.0' });
|
||||
// The chooser says the row is the model in use.
|
||||
expect(status.recommendation.pick?.library).toBe('active');
|
||||
expect(status.install.actions).toEqual(['start-session', 'stop', 'install', 'remove']);
|
||||
|
||||
// ---- B: a second model. Refused while a session uses A. ----
|
||||
const b = [status.recommendation.pick!, ...status.recommendation.alternatives].find(
|
||||
(row) => row.modelId === bRow.modelId
|
||||
)!;
|
||||
// The runtime is on disk now, so the server's total for B is the model alone.
|
||||
expect(b.installBytes).toBe(b.downloadBytes);
|
||||
const addB = { modelId: b.modelId, cli: 'opencode', context: b.context, acknowledgedBytes: b.installBytes };
|
||||
world.sessions.set('s1', { name: 'w1-app', customModel: { endpointId: 'local-ollama' } });
|
||||
const busy = await post('/api/local-model/install', addB);
|
||||
expect(busy.statusCode).toBe(409);
|
||||
expect(busy.json().data).toMatchObject({ reason: 'sessions-open', sessions: ['w1-app'] });
|
||||
expect(runtime.state.pulls).toBe(1);
|
||||
world.sessions.clear();
|
||||
|
||||
// The fake runtime reports the context it was "started with": B's from here on.
|
||||
runtime.switches.ps.contextLength = b.context;
|
||||
const started = await post('/api/local-model/install', addB);
|
||||
expect(started.statusCode).toBe(200);
|
||||
expect(started.json().data).toMatchObject({ state: 'installing', model: { id: b.modelId } });
|
||||
const withB = await settled();
|
||||
expect(withB.error).toBeUndefined();
|
||||
expect(withB).toMatchObject({
|
||||
state: 'installed',
|
||||
model: { id: b.modelId, label: b.modelLabel },
|
||||
context: b.context,
|
||||
endpointId: 'local-ollama',
|
||||
});
|
||||
// Both are on disk, A was not deleted, and the ONE endpoint record lists the model in use.
|
||||
expect(runtime.state.installed).toBe(true);
|
||||
expect(runtime.state.others).toEqual([b.modelId]);
|
||||
expect(await hostsModels()).toEqual([b.modelId]);
|
||||
expect(runtime.requests.some((request) => request.path === '/api/delete')).toBe(false);
|
||||
await vi.waitFor(async () => {
|
||||
const library = (await get()).install.library;
|
||||
expect(library.map((row) => [row.id, row.active, row.status, row.actions])).toEqual([
|
||||
[b.modelId, true, 'ready', []],
|
||||
[a.modelId, false, 'ready', ['use', 'delete']],
|
||||
]);
|
||||
});
|
||||
const stored = await readLocalModelState();
|
||||
expect(stored.library?.map((entry) => [entry.id, entry.context, entry.digest])).toEqual([
|
||||
[a.modelId, a.context, runtime.model.digest],
|
||||
[b.modelId, b.context, B_DIGEST],
|
||||
]);
|
||||
// Only the model in use may be launched on the endpoint.
|
||||
expect(await world.service.launchRefusalMessage('local-ollama', b.modelId)).toBeUndefined();
|
||||
expect(await world.service.launchRefusalMessage('local-ollama', a.modelId)).toContain('is in use on this Mac');
|
||||
|
||||
// ---- The model in use cannot be deleted ----
|
||||
const active = await deleteModel(b.modelId);
|
||||
expect(active.statusCode).toBe(409);
|
||||
expect(active.json().data).toMatchObject({ reason: 'model-active' });
|
||||
expect(runtime.state.others).toEqual([b.modelId]);
|
||||
|
||||
// ---- Back to A: nothing is downloaded ----
|
||||
status = await get();
|
||||
expect(status.recommendation.pick?.library ?? status.recommendation.alternatives[0].library).toBeDefined();
|
||||
world.sessions.set('s1', { name: 'w1-app', customModel: { endpointId: 'local-ollama' } });
|
||||
const useBusy = await post('/api/local-model/use', { modelId: a.modelId });
|
||||
expect(useBusy.statusCode).toBe(409);
|
||||
expect(useBusy.json().data).toMatchObject({ reason: 'sessions-open' });
|
||||
world.sessions.clear();
|
||||
|
||||
runtime.switches.ps.contextLength = a.context;
|
||||
const pullsBefore = runtime.state.pulls;
|
||||
const switched = await post('/api/local-model/use', { modelId: a.modelId });
|
||||
expect(switched.statusCode).toBe(200);
|
||||
expect(switched.json().data).toMatchObject({ state: 'installing', steps: ['verify', 'connect'] });
|
||||
const backOnA = await settled();
|
||||
expect(backOnA.error).toBeUndefined();
|
||||
expect(backOnA).toMatchObject({ state: 'installed', model: { id: a.modelId }, context: a.context });
|
||||
expect(runtime.state.pulls).toBe(pullsBefore);
|
||||
expect(await hostsModels()).toEqual([a.modelId]);
|
||||
expect(await world.service.launchRefusalMessage('local-ollama', a.modelId)).toBeUndefined();
|
||||
|
||||
// ---- Delete B: through the runtime, by its name ----
|
||||
const notOurs = await deleteModel('someone-else:7b');
|
||||
expect(notOurs.statusCode).toBe(400);
|
||||
const gone = await deleteModel(b.modelId);
|
||||
expect(gone.statusCode).toBe(200);
|
||||
expect(gone.json().data.library.map((row: { id: string }) => row.id)).toEqual([a.modelId]);
|
||||
expect(runtime.state.others).toEqual([]);
|
||||
expect(runtime.state.installed).toBe(true);
|
||||
const deletes = runtime.requests.filter((request) => request.path === '/api/delete');
|
||||
expect(deletes.map((request) => request.body)).toEqual([{ model: b.modelId }]);
|
||||
expect((await readLocalModelState()).library?.map((entry) => entry.id)).toEqual([a.modelId]);
|
||||
expect((await get()).install.actions).toEqual(['start-session', 'stop', 'install', 'remove']);
|
||||
// A is in use: it can only go with the whole installation.
|
||||
expect((await deleteModel(a.modelId)).statusCode).toBe(409);
|
||||
});
|
||||
});
|
||||
|
||||
describe('a server that is killed leaves its runtime running: the next server never deletes under it', () => {
|
||||
/**
|
||||
* Installs up to the model download, then "kills" the server: its job stops and it signals
|
||||
|
||||
@@ -17,13 +17,18 @@ import {
|
||||
allowedActions,
|
||||
buildInstallError,
|
||||
buildInstallView,
|
||||
buildLibraryRows,
|
||||
formatInstallMessage,
|
||||
INSTALL_STATE_TABLE,
|
||||
INSTALL_STEP_TABLE,
|
||||
INSTALL_TRANSITIONS,
|
||||
installedBlocker,
|
||||
jobSteps,
|
||||
libraryOf,
|
||||
markLibraryCandidates,
|
||||
reduceInstallJob,
|
||||
upsertLibraryEntry,
|
||||
USE_JOB_STEPS,
|
||||
type InstallJobEvent,
|
||||
type InstallJobEventType,
|
||||
type InstallLiveFacts,
|
||||
@@ -38,12 +43,15 @@ import {
|
||||
type LocalModelErrorCode,
|
||||
type LocalModelInstallState,
|
||||
type LocalModelInstallStep,
|
||||
type LocalModelLibraryEntry,
|
||||
type LocalModelStateFile,
|
||||
type Recommendation,
|
||||
} from '../src/types.js';
|
||||
|
||||
const STATES: LocalModelInstallState[] = ['idle', 'installing', 'paused', 'failed', 'installed'];
|
||||
const EVENT_TYPES: InstallJobEventType[] = [
|
||||
'install',
|
||||
'use',
|
||||
'progress',
|
||||
'step-done',
|
||||
'fail',
|
||||
@@ -60,6 +68,48 @@ const RUNTIME = { version: '0.40.15', port: 11435, pid: 4711, enabled: true, mea
|
||||
const CONNECTION = { endpointId: 'local-ollama', flagWasEnabled: false };
|
||||
const LOADED = { context: 131072, sizeBytes: 26_300_000_000, sizeVramBytes: 26_300_000_000 };
|
||||
|
||||
/** The model in use as the library lists it, and a second checked model beside it. */
|
||||
const ENTRY: LocalModelLibraryEntry = {
|
||||
id: MODEL.id,
|
||||
label: MODEL.label,
|
||||
context: MODEL.context,
|
||||
digest: 'abc123',
|
||||
bytes: 23_105_798_956,
|
||||
cli: CLI,
|
||||
};
|
||||
const OTHER: LocalModelLibraryEntry = {
|
||||
id: 'qwen3.8:27b-mtp-q4_k_m',
|
||||
label: 'Qwen3.8 27B',
|
||||
context: 65536,
|
||||
digest: 'def456',
|
||||
bytes: 17_741_872_154,
|
||||
cli: CLI,
|
||||
};
|
||||
|
||||
/** The two as rows of the view, while nothing is known from the runtime. */
|
||||
const ENTRY_ROW = {
|
||||
id: ENTRY.id,
|
||||
label: ENTRY.label,
|
||||
context: ENTRY.context,
|
||||
sizeBytes: 23_105_798_956,
|
||||
sizeGb: '23.1',
|
||||
active: true,
|
||||
verified: true,
|
||||
status: 'ready',
|
||||
actions: [],
|
||||
};
|
||||
const OTHER_ROW = {
|
||||
id: OTHER.id,
|
||||
label: OTHER.label,
|
||||
context: OTHER.context,
|
||||
sizeBytes: 17_741_872_154,
|
||||
sizeGb: '17.7',
|
||||
active: false,
|
||||
verified: true,
|
||||
status: 'ready',
|
||||
actions: ['use'],
|
||||
};
|
||||
|
||||
const JOB: LocalModelStateFile = {
|
||||
schema: 1,
|
||||
state: 'installing',
|
||||
@@ -69,6 +119,8 @@ const JOB: LocalModelStateFile = {
|
||||
cli: CLI,
|
||||
runtime: RUNTIME,
|
||||
progress: { completedBytes: 6_200_000_000, totalBytes: 23_105_798_956 },
|
||||
// An earlier install left one checked model: what `use` can go back to.
|
||||
library: [OTHER],
|
||||
};
|
||||
|
||||
/** One state file of each state, as the job leaves it. */
|
||||
@@ -84,6 +136,7 @@ const SAMPLE: Record<LocalModelInstallState, LocalModelStateFile> = {
|
||||
cli: CLI,
|
||||
runtime: RUNTIME,
|
||||
loaded: LOADED,
|
||||
library: [OTHER],
|
||||
connection: CONNECTION,
|
||||
},
|
||||
};
|
||||
@@ -96,6 +149,11 @@ const EVENT: Record<InstallJobEventType, InstallJobEvent> = {
|
||||
cli: { id: 'claude', label: 'Claude Code' },
|
||||
steps: ['runtime', 'model', 'verify', 'connect'],
|
||||
},
|
||||
use: {
|
||||
type: 'use',
|
||||
model: { id: OTHER.id, label: OTHER.label, context: OTHER.context, digest: OTHER.digest },
|
||||
cli: CLI,
|
||||
},
|
||||
progress: { type: 'progress', completedBytes: 7_000_000_000, totalBytes: 23_105_798_956 },
|
||||
'step-done': { type: 'step-done' },
|
||||
fail: { type: 'fail', code: 'no-tool-call', vars: { model: 'Qwen3.6 35B-A3B' }, nextModelId: 'gpt-oss:20b' },
|
||||
@@ -151,12 +209,17 @@ describe('the tables', () => {
|
||||
cancel: 'paused',
|
||||
restart: 'paused',
|
||||
},
|
||||
paused: { resume: 'installing', install: 'installing', remove: 'idle', restart: 'paused' },
|
||||
failed: { resume: 'installing', install: 'installing', remove: 'idle', restart: 'failed' },
|
||||
installed: { remove: 'idle', restart: 'installed' },
|
||||
paused: { resume: 'installing', install: 'installing', use: 'installing', remove: 'idle', restart: 'paused' },
|
||||
failed: { resume: 'installing', install: 'installing', use: 'installing', remove: 'idle', restart: 'failed' },
|
||||
installed: { install: 'installing', use: 'installing', remove: 'idle', restart: 'installed' },
|
||||
});
|
||||
});
|
||||
|
||||
it('a switch is the two steps that load and connect a model, and downloads nothing', () => {
|
||||
expect(USE_JOB_STEPS).toEqual(['verify', 'connect']);
|
||||
for (const step of USE_JOB_STEPS) expect(INSTALL_STEP_TABLE[step].progress).toBe(false);
|
||||
});
|
||||
|
||||
it('every step row names error codes that exist, each with a sentence and a next step', () => {
|
||||
for (const step of LOCAL_MODEL_INSTALL_STEPS) {
|
||||
const row = INSTALL_STEP_TABLE[step];
|
||||
@@ -221,10 +284,10 @@ describe('reduceInstallJob: every state with every event, from the transition ta
|
||||
}
|
||||
}
|
||||
|
||||
it('covers all 40 pairs, 18 of them listed', () => {
|
||||
it('covers all 45 pairs, 22 of them listed', () => {
|
||||
const listed = STATES.flatMap((state) => Object.keys(INSTALL_TRANSITIONS[state]));
|
||||
expect(STATES.length * EVENT_TYPES.length).toBe(40);
|
||||
expect(listed).toHaveLength(18);
|
||||
expect(STATES.length * EVENT_TYPES.length).toBe(45);
|
||||
expect(listed).toHaveLength(22);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -246,11 +309,15 @@ describe('the state table agrees with the reducer and the view', () => {
|
||||
const row = INSTALL_STATE_TABLE[state];
|
||||
const accepts = (type: InstallJobEventType) => INSTALL_TRANSITIONS[state][type] !== undefined;
|
||||
expect(row.actions.includes('install')).toBe(accepts('install'));
|
||||
expect(row.actions.includes('use')).toBe(accepts('use'));
|
||||
expect(row.actions.includes('cancel')).toBe(accepts('cancel'));
|
||||
expect(row.actions.includes('resume')).toBe(accepts('resume'));
|
||||
// Remove is refused only while a job runs. From idle it only clears leftovers, so the
|
||||
// window offers no button for it there.
|
||||
if (state !== 'idle') expect(row.actions.includes('remove')).toBe(accepts('remove'));
|
||||
// Remove is refused only while a job runs. From idle it only clears what an earlier
|
||||
// Remove kept, and the view lists it there only while the store holds something.
|
||||
expect(row.actions.includes('remove')).toBe(accepts('remove'));
|
||||
// A model is deleted through the runtime, which a state without a job in flight and with
|
||||
// a runtime set up can have: never idle (no runtime), never while a job runs.
|
||||
expect(row.actions.includes('delete-model')).toBe(state !== 'idle' && state !== 'installing');
|
||||
// The runtime and a session are things of an installed model only.
|
||||
for (const action of ['start', 'stop', 'start-session'] as LocalModelAction[]) {
|
||||
expect(row.actions.includes(action)).toBe(state === 'installed');
|
||||
@@ -292,6 +359,7 @@ describe('reduceInstallJob: what each event writes', () => {
|
||||
replacedModelId: 'qwen3.6:35b-a3b',
|
||||
cli: { id: 'claude', label: 'Claude Code' },
|
||||
runtime: RUNTIME,
|
||||
library: [OTHER],
|
||||
connection: CONNECTION,
|
||||
});
|
||||
// Gone: the error, the progress, what the runtime had loaded.
|
||||
@@ -421,32 +489,65 @@ describe('reduceInstallJob: what each event writes', () => {
|
||||
});
|
||||
|
||||
describe('allowedActions', () => {
|
||||
it('outside installed it is the state row, whatever is live', () => {
|
||||
for (const state of ['idle', 'installing', 'paused', 'failed'] as const) {
|
||||
expect(allowedActions(SAMPLE[state], { ...LIVE, running: true, gaveUp: true, portInUse: true })).toEqual([
|
||||
...INSTALL_STATE_TABLE[state].actions,
|
||||
]);
|
||||
}
|
||||
expect(allowedActions(SAMPLE.idle, LIVE)).toEqual(['install']);
|
||||
it('outside installed the runtime, the port and the digest narrow nothing', () => {
|
||||
const busy = { ...LIVE, running: true, gaveUp: true, portInUse: true };
|
||||
expect(allowedActions(SAMPLE.installing, busy)).toEqual(['cancel']);
|
||||
expect(allowedActions(SAMPLE.installing, LIVE)).toEqual(['cancel']);
|
||||
expect(allowedActions(SAMPLE.paused, LIVE)).toEqual(['resume', 'install', 'remove']);
|
||||
expect(allowedActions(SAMPLE.failed, LIVE)).toEqual(['resume', 'install', 'remove']);
|
||||
for (const state of ['paused', 'failed'] as const) {
|
||||
// `use` because a checked model is there; no delete while nothing is known from the runtime.
|
||||
expect(allowedActions(SAMPLE[state], busy)).toEqual(['resume', 'install', 'use', 'remove']);
|
||||
expect(allowedActions(SAMPLE[state], LIVE)).toEqual(['resume', 'install', 'use', 'remove']);
|
||||
}
|
||||
});
|
||||
|
||||
it('installed and running: a session can be started and the runtime stopped', () => {
|
||||
it('idle: Remove is offered only while the model store still holds something', () => {
|
||||
expect(allowedActions(SAMPLE.idle, LIVE)).toEqual(['install']);
|
||||
expect(allowedActions(SAMPLE.idle, { ...LIVE, storeBytes: 0 })).toEqual(['install']);
|
||||
expect(allowedActions(SAMPLE.idle, { ...LIVE, storeBytes: 23_105_798_956 })).toEqual(['install', 'remove']);
|
||||
});
|
||||
|
||||
it('use and delete-model are listed only while a row of the library offers them', () => {
|
||||
// A first job: no checked model yet, so nothing to switch to and nothing to delete.
|
||||
const first: LocalModelStateFile = { ...SAMPLE.paused, library: undefined };
|
||||
expect(allowedActions(first, LIVE)).toEqual(['resume', 'install', 'remove']);
|
||||
// The runtime lists the other model: it can be deleted as well as switched to.
|
||||
const tags = [{ name: OTHER.id, size: 17_741_872_154, digest: OTHER.digest }];
|
||||
expect(allowedActions(SAMPLE.paused, { ...LIVE, running: true, tags })).toEqual([
|
||||
'resume',
|
||||
'install',
|
||||
'use',
|
||||
'delete-model',
|
||||
'remove',
|
||||
]);
|
||||
// The rows it was given decide, when it is given them.
|
||||
expect(allowedActions(SAMPLE.paused, { ...LIVE, running: true, tags }, [])).toEqual([
|
||||
'resume',
|
||||
'install',
|
||||
'remove',
|
||||
]);
|
||||
});
|
||||
|
||||
it('installed and running: a session can be started, the runtime stopped, a model added', () => {
|
||||
expect(allowedActions(SAMPLE.installed, { ...LIVE, running: true, digest: 'same' })).toEqual([
|
||||
'start-session',
|
||||
'stop',
|
||||
'install',
|
||||
'use',
|
||||
'remove',
|
||||
]);
|
||||
});
|
||||
|
||||
it('installed and stopped: it can be started, and no session is offered', () => {
|
||||
expect(allowedActions(SAMPLE.installed, LIVE)).toEqual(['start', 'remove']);
|
||||
expect(allowedActions(SAMPLE.installed, LIVE)).toEqual(['start', 'install', 'use', 'remove']);
|
||||
});
|
||||
|
||||
it('installed and running a model that changed: no session', () => {
|
||||
expect(allowedActions(SAMPLE.installed, { ...LIVE, running: true, digest: 'changed' })).toEqual(['stop', 'remove']);
|
||||
expect(allowedActions(SAMPLE.installed, { ...LIVE, running: true, digest: 'changed' })).toEqual([
|
||||
'stop',
|
||||
'install',
|
||||
'use',
|
||||
'remove',
|
||||
]);
|
||||
});
|
||||
|
||||
it('a digest that could not be read does not hide the session button: the launch itself reads it again', () => {
|
||||
@@ -456,8 +557,8 @@ describe('allowedActions', () => {
|
||||
|
||||
it('never returns the table row itself', () => {
|
||||
const actions = allowedActions(SAMPLE.idle, LIVE);
|
||||
actions.push('remove');
|
||||
expect(INSTALL_STATE_TABLE.idle.actions).toEqual(['install']);
|
||||
actions.push('cancel');
|
||||
expect(INSTALL_STATE_TABLE.idle.actions).toEqual(['install', 'remove']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -535,10 +636,20 @@ describe('buildInstallView', () => {
|
||||
unpackedGb: '0.6',
|
||||
},
|
||||
folder: '/Users/me/.codeman/local-model',
|
||||
library: [],
|
||||
actions: ['install'],
|
||||
});
|
||||
});
|
||||
|
||||
it('idle with a store an earlier Remove kept: its size, and Remove to free it', () => {
|
||||
const view = buildInstallView(SAMPLE.idle, { ...LIVE, storeBytes: 23_105_798_956 });
|
||||
expect(view.store).toEqual({ bytes: 23_105_798_956, gb: '23.1' });
|
||||
expect(view.library).toEqual([]);
|
||||
expect(view.actions).toEqual(['install', 'remove']);
|
||||
// A store that was measured as empty is said as that, and offers nothing.
|
||||
expect(buildInstallView(SAMPLE.idle, { ...LIVE, storeBytes: 0 }).store).toEqual({ bytes: 0, gb: '0.0' });
|
||||
});
|
||||
|
||||
it('once the runtime is unpacked an install downloads nothing for it', () => {
|
||||
const view = buildInstallView(SAMPLE.idle, { ...LIVE, runtimeInstalled: true });
|
||||
expect(view.runtime).toEqual({
|
||||
@@ -564,6 +675,8 @@ describe('buildInstallView', () => {
|
||||
unpackedGb: '0.6',
|
||||
},
|
||||
folder: '/Users/me/.codeman/local-model',
|
||||
// The checked model of an earlier install: listed, with nothing to do while a job runs.
|
||||
library: [{ ...OTHER_ROW, actions: [] }],
|
||||
actions: ['cancel'],
|
||||
model: { id: 'qwen3.6:35b-a3b', label: 'Qwen3.6 35B-A3B' },
|
||||
context: 131072,
|
||||
@@ -593,7 +706,9 @@ describe('buildInstallView', () => {
|
||||
completedGb: '6.2',
|
||||
totalGb: '23.1',
|
||||
});
|
||||
expect(view.actions).toEqual(['resume', 'install', 'remove']);
|
||||
expect(view.actions).toEqual(['resume', 'install', 'use', 'remove']);
|
||||
// The way back from a job that stopped: the model that was checked before.
|
||||
expect(view.library).toEqual([OTHER_ROW]);
|
||||
});
|
||||
|
||||
it('a speed of zero has no time left, and a download without a total has no progress at all', () => {
|
||||
@@ -677,7 +792,8 @@ describe('buildInstallView', () => {
|
||||
developerTools: 'present',
|
||||
runtime: { version: '0.40.15', installed: true, downloadBytes: 0, downloadGb: '0.0', unpackedGb: '0.6' },
|
||||
folder: '/Users/me/.codeman/local-model',
|
||||
actions: ['start-session', 'stop', 'remove'],
|
||||
library: [ENTRY_ROW, OTHER_ROW],
|
||||
actions: ['start-session', 'stop', 'install', 'use', 'remove'],
|
||||
model: { id: 'qwen3.6:35b-a3b', label: 'Qwen3.6 35B-A3B' },
|
||||
context: 131072,
|
||||
cli: 'opencode',
|
||||
@@ -692,7 +808,9 @@ describe('buildInstallView', () => {
|
||||
const view = buildInstallView(SAMPLE.installed, { ...LIVE, running: true, digest: 'changed' });
|
||||
expect(view.state).toBe('installed');
|
||||
expect(view.error?.code).toBe('model-changed');
|
||||
expect(view.actions).toEqual(['stop', 'remove']);
|
||||
expect(view.actions).toEqual(['stop', 'install', 'use', 'remove']);
|
||||
// The row of the model in use says the same thing the error does.
|
||||
expect(view.library[0]).toMatchObject({ id: MODEL.id, active: true, status: 'changed' });
|
||||
});
|
||||
|
||||
it('a model that was not all on the GPU says so', () => {
|
||||
@@ -713,6 +831,317 @@ describe('buildInstallView', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('reduceInstallJob: the library', () => {
|
||||
it('libraryOf: the list of the file, and for an installed file without one its model', () => {
|
||||
expect(libraryOf(SAMPLE.idle)).toEqual([]);
|
||||
expect(libraryOf(SAMPLE.paused)).toEqual([OTHER]);
|
||||
// A file an earlier build wrote: the model in use is the library.
|
||||
const old: LocalModelStateFile = { ...SAMPLE.installed, library: undefined };
|
||||
expect(libraryOf(old)).toEqual([ENTRY]);
|
||||
// The model in use is always one of them, first, also when the list does not name it.
|
||||
expect(libraryOf(SAMPLE.installed)).toEqual([ENTRY, OTHER]);
|
||||
// Never twice, and the list's own entry wins.
|
||||
const listed: LocalModelStateFile = { ...SAMPLE.installed, library: [OTHER, { ...ENTRY, context: 65536 }] };
|
||||
expect(libraryOf(listed)).toEqual([OTHER, { ...ENTRY, context: 65536 }]);
|
||||
// A model without a stored digest could never be compared: it is not one of them.
|
||||
const noDigest: LocalModelStateFile = { ...SAMPLE.installed, library: undefined, model: MODEL };
|
||||
expect(libraryOf(noDigest)).toEqual([]);
|
||||
// A job's model is not in the library before it was checked.
|
||||
expect(libraryOf({ ...SAMPLE.installing, library: undefined })).toEqual([]);
|
||||
});
|
||||
|
||||
it('libraryOf hands out a list of its own', () => {
|
||||
const list = libraryOf(SAMPLE.paused);
|
||||
list.pop();
|
||||
expect(SAMPLE.paused.library).toEqual([OTHER]);
|
||||
});
|
||||
|
||||
it('upsertLibraryEntry replaces the entry with the same id in place, else adds at the end', () => {
|
||||
const next = { ...OTHER, digest: 'fff', context: 131072 };
|
||||
expect(upsertLibraryEntry([ENTRY, OTHER], next)).toEqual([ENTRY, next]);
|
||||
expect(upsertLibraryEntry([ENTRY], OTHER)).toEqual([ENTRY, OTHER]);
|
||||
expect(upsertLibraryEntry([], OTHER)).toEqual([OTHER]);
|
||||
});
|
||||
|
||||
it('an install from installed adds a model: the model in use stays in the library and is NOT deleted', () => {
|
||||
const next = reduceInstallJob(SAMPLE.installed, EVENT.install);
|
||||
expect(next).toEqual({
|
||||
schema: 1,
|
||||
state: 'installing',
|
||||
step: 'runtime',
|
||||
steps: ['runtime', 'model', 'verify', 'connect'],
|
||||
model: { id: 'gpt-oss:20b', label: 'GPT-OSS 20B', context: 65536 },
|
||||
cli: { id: 'claude', label: 'Claude Code' },
|
||||
runtime: RUNTIME,
|
||||
// The model that was in use is written into the list: its state member is the new job's now.
|
||||
library: [ENTRY, OTHER],
|
||||
connection: CONNECTION,
|
||||
});
|
||||
expect(next).not.toHaveProperty('replacedModelId');
|
||||
expect(next).not.toHaveProperty('loaded');
|
||||
});
|
||||
|
||||
it('an install from an installed file of an earlier build keeps its one model too', () => {
|
||||
const old: LocalModelStateFile = { ...SAMPLE.installed, library: undefined };
|
||||
const next = reduceInstallJob(old, EVENT.install);
|
||||
expect(next.library).toEqual([ENTRY]);
|
||||
expect(next).not.toHaveProperty('replacedModelId');
|
||||
});
|
||||
|
||||
it('a replaced job deletes its model only when that model was never checked', () => {
|
||||
// The stopped job's model is a checked one (it failed after its check): it stays.
|
||||
const checked: LocalModelStateFile = { ...SAMPLE.failed, library: [OTHER, ENTRY] };
|
||||
expect(reduceInstallJob(checked, EVENT.install)).not.toHaveProperty('replacedModelId');
|
||||
// Not checked: what it left is deleted, as before.
|
||||
expect(reduceInstallJob(SAMPLE.failed, EVENT.install).replacedModelId).toBe(MODEL.id);
|
||||
// A leftover that is waiting to be deleted is dropped from the wait once it is a checked model.
|
||||
const waiting: LocalModelStateFile = { ...SAMPLE.paused, replacedModelId: OTHER.id };
|
||||
expect(reduceInstallJob(waiting, { ...EVENT.install, model: MODEL } as InstallJobEvent)).not.toHaveProperty(
|
||||
'replacedModelId'
|
||||
);
|
||||
});
|
||||
|
||||
it('use from installed: the two steps for the checked model, with its stored digest', () => {
|
||||
expect(reduceInstallJob(SAMPLE.installed, EVENT.use)).toEqual({
|
||||
schema: 1,
|
||||
state: 'installing',
|
||||
step: 'verify',
|
||||
steps: ['verify', 'connect'],
|
||||
model: { id: OTHER.id, label: OTHER.label, context: OTHER.context, digest: OTHER.digest },
|
||||
cli: CLI,
|
||||
runtime: RUNTIME,
|
||||
library: [ENTRY, OTHER],
|
||||
connection: CONNECTION,
|
||||
});
|
||||
});
|
||||
|
||||
it('use from a stopped job replaces it, and deletes what that job left of an unchecked model', () => {
|
||||
for (const state of ['paused', 'failed'] as const) {
|
||||
const next = reduceInstallJob(SAMPLE[state], EVENT.use);
|
||||
expect(next.state).toBe('installing');
|
||||
expect(next.step).toBe('verify');
|
||||
expect(next.model?.id).toBe(OTHER.id);
|
||||
expect(next.replacedModelId).toBe(MODEL.id);
|
||||
expect(next).not.toHaveProperty('error');
|
||||
expect(next).not.toHaveProperty('progress');
|
||||
expect(next.library).toEqual([OTHER]);
|
||||
}
|
||||
});
|
||||
|
||||
it('refuses a use it cannot apply: a model that is not in the library, a state without a runtime', () => {
|
||||
const unknown = { ...EVENT.use, model: { id: 'gpt-oss:20b', label: 'GPT-OSS 20B', context: 65536, digest: 'x' } };
|
||||
expect(reduceInstallJob(SAMPLE.installed, unknown as InstallJobEvent)).toBe(SAMPLE.installed);
|
||||
const noRuntime: LocalModelStateFile = { ...SAMPLE.paused, runtime: undefined };
|
||||
expect(reduceInstallJob(noRuntime, EVENT.use)).toBe(noRuntime);
|
||||
});
|
||||
|
||||
it('every event but remove carries the library', () => {
|
||||
const job = SAMPLE.installing;
|
||||
expect(reduceInstallJob(job, EVENT.progress).library).toEqual([OTHER]);
|
||||
expect(reduceInstallJob(job, EVENT['step-done']).library).toEqual([OTHER]);
|
||||
expect(reduceInstallJob(job, EVENT.fail).library).toEqual([OTHER]);
|
||||
expect(reduceInstallJob(job, EVENT.cancel).library).toEqual([OTHER]);
|
||||
expect(reduceInstallJob(job, EVENT.restart).library).toEqual([OTHER]);
|
||||
expect(reduceInstallJob(SAMPLE.failed, EVENT.resume).library).toEqual([OTHER]);
|
||||
const last: LocalModelStateFile = { ...job, step: 'connect', library: [OTHER, ENTRY], connection: CONNECTION };
|
||||
expect(reduceInstallJob(last, EVENT['step-done'])).toMatchObject({ state: 'installed', library: [OTHER, ENTRY] });
|
||||
expect(reduceInstallJob(SAMPLE.installed, EVENT.remove)).toEqual({ schema: 1, state: 'idle' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildLibraryRows', () => {
|
||||
const tagOf = (entry: LocalModelLibraryEntry, extra: Partial<{ size: number; digest: string }> = {}) => ({
|
||||
name: entry.id,
|
||||
size: entry.bytes ?? 0,
|
||||
digest: entry.digest,
|
||||
...extra,
|
||||
});
|
||||
|
||||
it('is empty in idle, whatever is live', () => {
|
||||
expect(buildLibraryRows(SAMPLE.idle, { ...LIVE, running: true, tags: [tagOf(OTHER)] })).toEqual([]);
|
||||
});
|
||||
|
||||
it('the runtime stopped: the checked models as they were recorded, a switch, and no delete', () => {
|
||||
expect(buildLibraryRows(SAMPLE.installed, LIVE)).toEqual([ENTRY_ROW, OTHER_ROW]);
|
||||
// A listing that is still around from before the stop is not trusted.
|
||||
expect(buildLibraryRows(SAMPLE.installed, { ...LIVE, running: false, tags: [] })).toEqual([ENTRY_ROW, OTHER_ROW]);
|
||||
});
|
||||
|
||||
it('the runtime running: its sizes, and a delete for every model but the one in use', () => {
|
||||
const rows = buildLibraryRows(SAMPLE.installed, {
|
||||
...LIVE,
|
||||
running: true,
|
||||
tags: [tagOf(ENTRY, { size: 23_105_800_000 }), tagOf(OTHER)],
|
||||
});
|
||||
expect(rows).toEqual([
|
||||
{ ...ENTRY_ROW, sizeBytes: 23_105_800_000 },
|
||||
{ ...OTHER_ROW, actions: ['use', 'delete'] },
|
||||
]);
|
||||
});
|
||||
|
||||
it('the model in use comes first, wherever the list has it', () => {
|
||||
const state: LocalModelStateFile = { ...SAMPLE.installed, library: [OTHER, ENTRY] };
|
||||
expect(buildLibraryRows(state, LIVE).map((row) => row.id)).toEqual([ENTRY.id, OTHER.id]);
|
||||
});
|
||||
|
||||
it('a checked model whose digest the runtime no longer reports: changed, never switched to, deletable', () => {
|
||||
const rows = buildLibraryRows(SAMPLE.installed, {
|
||||
...LIVE,
|
||||
running: true,
|
||||
tags: [tagOf(ENTRY), tagOf(OTHER, { digest: 'somethingelse' })],
|
||||
});
|
||||
expect(rows[1]).toMatchObject({ id: OTHER.id, status: 'changed', verified: true, actions: ['delete'] });
|
||||
// The digest is compared without its prefix and its case, like everywhere else.
|
||||
const same = buildLibraryRows(SAMPLE.installed, {
|
||||
...LIVE,
|
||||
running: true,
|
||||
tags: [tagOf(ENTRY), tagOf(OTHER, { digest: 'sha256:DEF456' })],
|
||||
});
|
||||
expect(same[1].status).toBe('ready');
|
||||
});
|
||||
|
||||
it('a checked model the runtime no longer lists: missing, with its recorded size, and its entry can be dropped', () => {
|
||||
const rows = buildLibraryRows(SAMPLE.installed, { ...LIVE, running: true, tags: [tagOf(ENTRY)] });
|
||||
expect(rows[1]).toEqual({ ...OTHER_ROW, status: 'missing', actions: ['delete'] });
|
||||
});
|
||||
|
||||
it('what the runtime lists beside the checked models: not checked, deletable, never switched to', () => {
|
||||
const rows = buildLibraryRows(SAMPLE.installed, {
|
||||
...LIVE,
|
||||
running: true,
|
||||
labels: { 'mimo-v2.6:9b-q4_k_m': 'MiMo V2.6 9B' },
|
||||
tags: [
|
||||
tagOf(ENTRY),
|
||||
tagOf(OTHER),
|
||||
{ name: 'zeta:1b', size: 900_000_000, digest: 'z' },
|
||||
{ name: 'mimo-v2.6:9b-q4_k_m', size: 6_550_811_033, digest: 'm' },
|
||||
],
|
||||
});
|
||||
expect(rows.slice(2)).toEqual([
|
||||
{
|
||||
id: 'mimo-v2.6:9b-q4_k_m',
|
||||
// The catalogue knows it, so it is named as the chooser names it.
|
||||
label: 'MiMo V2.6 9B',
|
||||
sizeBytes: 6_550_811_033,
|
||||
sizeGb: '6.6',
|
||||
active: false,
|
||||
verified: false,
|
||||
status: 'unverified',
|
||||
actions: ['delete'],
|
||||
},
|
||||
{
|
||||
id: 'zeta:1b',
|
||||
label: 'zeta:1b',
|
||||
sizeBytes: 900_000_000,
|
||||
sizeGb: '0.9',
|
||||
active: false,
|
||||
verified: false,
|
||||
status: 'unverified',
|
||||
actions: ['delete'],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('a name that is not a plain name:tag is listed with its size and offers nothing', () => {
|
||||
const rows = buildLibraryRows(SAMPLE.installed, {
|
||||
...LIVE,
|
||||
running: true,
|
||||
tags: [tagOf(ENTRY), { name: 'hf.co/someone/model:Q4', size: 1_000_000_000, digest: 'h' }],
|
||||
});
|
||||
expect(rows.find((row) => row.id === 'hf.co/someone/model:Q4')).toMatchObject({
|
||||
status: 'unverified',
|
||||
sizeGb: '1.0',
|
||||
actions: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('the model of a stopped job: listed once it is on disk, and neither switched to nor deleted', () => {
|
||||
const rows = buildLibraryRows(SAMPLE.failed, {
|
||||
...LIVE,
|
||||
running: true,
|
||||
tags: [tagOf(OTHER), { name: MODEL.id, size: 23_105_798_956, digest: 'abc123' }],
|
||||
});
|
||||
expect(rows).toEqual([
|
||||
{ ...OTHER_ROW, actions: ['use', 'delete'] },
|
||||
{
|
||||
id: MODEL.id,
|
||||
label: MODEL.label,
|
||||
sizeBytes: 23_105_798_956,
|
||||
sizeGb: '23.1',
|
||||
active: false,
|
||||
verified: false,
|
||||
status: 'unverified',
|
||||
actions: [],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('while a job runs nothing is offered on any row', () => {
|
||||
const rows = buildLibraryRows(SAMPLE.installing, { ...LIVE, running: true, tags: [tagOf(OTHER)] });
|
||||
expect(rows.map((row) => row.actions)).toEqual([[]]);
|
||||
});
|
||||
|
||||
it('a row without a known size has neither size member', () => {
|
||||
const state: LocalModelStateFile = { ...SAMPLE.paused, library: [{ ...OTHER, bytes: undefined }] };
|
||||
const [row] = buildLibraryRows(state, LIVE);
|
||||
expect(row).not.toHaveProperty('sizeBytes');
|
||||
expect(row).not.toHaveProperty('sizeGb');
|
||||
});
|
||||
});
|
||||
|
||||
describe('markLibraryCandidates', () => {
|
||||
const candidate = (modelId: string, context: number) => ({ modelId, context }) as Recommendation['alternatives'][0];
|
||||
const view = buildInstallView(SAMPLE.installed, { ...LIVE, running: true });
|
||||
|
||||
it('marks the model in use and a model a switch can take, at the context they were checked with', () => {
|
||||
const recommendation = {
|
||||
verdict: 'ok',
|
||||
pick: candidate(MODEL.id, MODEL.context),
|
||||
alternatives: [candidate(OTHER.id, OTHER.context), candidate('gpt-oss:20b', 65536)],
|
||||
} as unknown as Recommendation;
|
||||
const marked = markLibraryCandidates(recommendation, view);
|
||||
expect(marked.pick?.library).toBe('active');
|
||||
expect(marked.alternatives[0].library).toBe('ready');
|
||||
expect(marked.alternatives[1]).not.toHaveProperty('library');
|
||||
// The input is not written to.
|
||||
expect(recommendation.pick).not.toHaveProperty('library');
|
||||
});
|
||||
|
||||
it('a row at another context than the one that was checked is an install like any other', () => {
|
||||
const recommendation = {
|
||||
verdict: 'ok',
|
||||
pick: candidate(MODEL.id, 65536),
|
||||
alternatives: [candidate(OTHER.id, 131072)],
|
||||
} as unknown as Recommendation;
|
||||
expect(markLibraryCandidates(recommendation, view)).toBe(recommendation);
|
||||
});
|
||||
|
||||
it('hands back the same object when there is nothing to mark', () => {
|
||||
const none = { verdict: 'none', alternatives: [] } as unknown as Recommendation;
|
||||
expect(markLibraryCandidates(none, view)).toBe(none);
|
||||
const idle = buildInstallView(SAMPLE.idle, LIVE);
|
||||
const recommendation = { verdict: 'ok', pick: candidate(MODEL.id, MODEL.context), alternatives: [] };
|
||||
expect(markLibraryCandidates(recommendation as unknown as Recommendation, idle)).toBe(recommendation);
|
||||
});
|
||||
|
||||
it('a checked model that changed is not marked ready', () => {
|
||||
const changed = buildInstallView(SAMPLE.installed, {
|
||||
...LIVE,
|
||||
running: true,
|
||||
tags: [
|
||||
{ name: MODEL.id, size: 1, digest: 'abc123' },
|
||||
{ name: OTHER.id, size: 1, digest: 'other' },
|
||||
],
|
||||
});
|
||||
const recommendation = {
|
||||
verdict: 'ok',
|
||||
pick: candidate(OTHER.id, OTHER.context),
|
||||
alternatives: [],
|
||||
} as unknown as Recommendation;
|
||||
expect(markLibraryCandidates(recommendation, changed).pick).not.toHaveProperty('library');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatInstallMessage', () => {
|
||||
it('fills the slots of a sentence', () => {
|
||||
expect(formatInstallMessage({ text: LOCAL_MODEL_INSTALL_LABELS.errPortInUse, vars: { port: '11435' } })).toBe(
|
||||
|
||||
@@ -13,7 +13,9 @@
|
||||
* Cancel in every step; Resume; a job a server restart interrupted comes back paused and
|
||||
* waits for a click; every failure is a state with a code and a next step; the coding agent
|
||||
* install (the stock command, the audit line, the resolver caches forgotten, still missing is
|
||||
* a failure); the digest that refuses a changed model; the runtime verbs; Remove.
|
||||
* a failure); the digest that refuses a changed model; the runtime verbs; Remove; and the
|
||||
* library: a second model beside the first, the switch between checked models, the delete of
|
||||
* one model, what a stopped job leaves to go back to, and the two cached listings of the view.
|
||||
*
|
||||
* Port: N/A (nothing listens).
|
||||
*/
|
||||
@@ -259,6 +261,10 @@ function setup(
|
||||
running: false,
|
||||
gaveUp: false,
|
||||
pid: 5000,
|
||||
/** What the runtime lists on disk. Default: the model of the state, as it was pulled. */
|
||||
tags: undefined as { name: string; size: number; digest: string }[] | undefined,
|
||||
/** What the model store takes on disk. */
|
||||
storeBytes: 0,
|
||||
};
|
||||
const calls: string[] = [];
|
||||
|
||||
@@ -325,14 +331,20 @@ function setup(
|
||||
{ name: 'qwen3.6:35b-a3b', size: 26_300_000_000, sizeVram: 26_300_000_000, contextLength: 32768 },
|
||||
]),
|
||||
getRuntimeVersion: vi.fn(async () => '0.40.15' as string | undefined),
|
||||
deleteModel: vi.fn(async () => {
|
||||
deleteModel: vi.fn(async (_target: unknown, _id: string) => {
|
||||
calls.push('deleteModel');
|
||||
return true;
|
||||
}),
|
||||
listTags: vi.fn(async () => {
|
||||
if (world.tags) return world.tags;
|
||||
const model = store.get().model;
|
||||
return model ? [{ name: model.id, size: PULL_BYTES, digest: DIGEST }] : [];
|
||||
}),
|
||||
removePartialModelBlobs: vi.fn(async () => {
|
||||
calls.push('removePartialModelBlobs');
|
||||
return 0;
|
||||
}),
|
||||
measureModelStore: vi.fn(async () => world.storeBytes),
|
||||
checkDeveloperTools: vi.fn(async () => 'missing' as const),
|
||||
stockInstallCommand: vi.fn((id: string) => (id === FACTS[0].cli ? INSTALL_COMMAND : undefined)),
|
||||
tryBeginCliInstall: vi.fn((): (() => void) | null => () => {
|
||||
@@ -470,7 +482,7 @@ describe('a job from the click to installed', () => {
|
||||
port: 11435,
|
||||
loaded: { context: 131072, memoryBytes: 26_300_000_000, memoryGb: '26.3', onGpu: true },
|
||||
endpointId: 'local-ollama',
|
||||
actions: ['start-session', 'stop', 'remove'],
|
||||
actions: ['start-session', 'stop', 'install', 'remove'],
|
||||
folder: '/data/local-model',
|
||||
runtime: { version: '0.40.15', installed: true, downloadBytes: 0 },
|
||||
});
|
||||
@@ -699,6 +711,8 @@ describe('one job at a time', () => {
|
||||
const jobRunning = { ok: false, status: 409, conflict: { reason: 'job-running' } };
|
||||
expect(await h.service.install(REQUEST, ADMIN)).toMatchObject(jobRunning);
|
||||
expect(await h.service.resume({}, ADMIN)).toMatchObject(jobRunning);
|
||||
expect(await h.service.use({ modelId: 'gpt-oss:20b' }, ADMIN)).toMatchObject(jobRunning);
|
||||
expect(await h.service.deleteLibraryModel({ modelId: 'gpt-oss:20b' }, ADMIN)).toMatchObject(jobRunning);
|
||||
expect(await h.service.runtime({ action: 'stop' })).toMatchObject(jobRunning);
|
||||
expect(await h.service.remove({}, [])).toMatchObject(jobRunning);
|
||||
expect(h.deps.ensureRuntimeInstalled).toHaveBeenCalledTimes(1);
|
||||
@@ -718,7 +732,7 @@ describe('one job at a time', () => {
|
||||
expect(h.deps.ensureRuntimeInstalled).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('install while a model is installed is 409 already-installed: remove it first', async () => {
|
||||
it('an install of the model that is in use, at its context, is 409 already-installed', async () => {
|
||||
const h = setup({ state: INSTALLED });
|
||||
expect(await h.service.install(REQUEST, ADMIN)).toEqual({
|
||||
ok: false,
|
||||
@@ -1073,7 +1087,11 @@ describe('boot with a model installed', () => {
|
||||
// The pid of the child that runs now replaces the stored one.
|
||||
expect(h.store.get().runtime?.pid).toBe(5000);
|
||||
const view = await h.service.view();
|
||||
expect(view).toMatchObject({ state: 'installed', running: true, actions: ['start-session', 'stop', 'remove'] });
|
||||
expect(view).toMatchObject({
|
||||
state: 'installed',
|
||||
running: true,
|
||||
actions: ['start-session', 'stop', 'install', 'remove'],
|
||||
});
|
||||
expect(view).not.toHaveProperty('error');
|
||||
});
|
||||
|
||||
@@ -1107,7 +1125,7 @@ describe('boot with a model installed', () => {
|
||||
await h.service.boot();
|
||||
expect(h.supervisor.start).not.toHaveBeenCalled();
|
||||
const view = await h.service.view();
|
||||
expect(view).toMatchObject({ state: 'installed', running: false, actions: ['start', 'remove'] });
|
||||
expect(view).toMatchObject({ state: 'installed', running: false, actions: ['start', 'install', 'remove'] });
|
||||
// A stopped runtime is not an error.
|
||||
expect(view).not.toHaveProperty('error');
|
||||
});
|
||||
@@ -1124,7 +1142,7 @@ describe('boot with a model installed', () => {
|
||||
message: { text: 'The model on this Mac changed since it was installed. Remove it and install it again.' },
|
||||
next: 'remove',
|
||||
});
|
||||
expect(view.actions).toEqual(['stop', 'remove']);
|
||||
expect(view.actions).toEqual(['stop', 'install', 'remove']);
|
||||
expect(h.store.get().state).toBe('installed');
|
||||
expect(h.store.get()).not.toHaveProperty('error');
|
||||
});
|
||||
@@ -1140,7 +1158,7 @@ describe('boot with a model installed', () => {
|
||||
message: { text: 'Port {port} is in use by another program.', vars: { port: '11435' } },
|
||||
next: 'other-port',
|
||||
});
|
||||
expect(view.actions).toEqual(['start', 'remove']);
|
||||
expect(view.actions).toEqual(['start', 'install', 'remove']);
|
||||
});
|
||||
|
||||
it('never throws, whatever the runtime does', async () => {
|
||||
@@ -1536,7 +1554,7 @@ describe('launchRefusal: the digest and the runtime decide whether a session sta
|
||||
'The model on this Mac changed since it was installed. Remove it and install it again.'
|
||||
);
|
||||
// And the window stops offering a session.
|
||||
expect((await h.service.view()).actions).toEqual(['stop', 'remove']);
|
||||
expect((await h.service.view()).actions).toEqual(['stop', 'install', 'remove']);
|
||||
});
|
||||
|
||||
it('a digest with the sha256 prefix some listings add is the same digest', async () => {
|
||||
@@ -1602,7 +1620,7 @@ describe('launchRefusal: the digest and the runtime decide whether a session sta
|
||||
it('only the model that was installed and checked may be started on the managed endpoint', async () => {
|
||||
const h = await installed();
|
||||
expect(await h.service.launchRefusalMessage('local-ollama', 'someone-else:7b')).toBe(
|
||||
'Only Qwen3.6 35B-A3B is installed on this Mac. Remove it and install another model first.'
|
||||
'Only Qwen3.6 35B-A3B is in use on this Mac. Switch models in the local model window first.'
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1641,7 +1659,7 @@ describe('the runtime verbs', () => {
|
||||
expect(stopped.ok && stopped.view).toMatchObject({
|
||||
state: 'installed',
|
||||
running: false,
|
||||
actions: ['start', 'remove'],
|
||||
actions: ['start', 'install', 'remove'],
|
||||
});
|
||||
expect(h.supervisor.stop).toHaveBeenCalledTimes(1);
|
||||
expect(h.store.get().runtime?.enabled).toBe(false);
|
||||
@@ -1652,7 +1670,10 @@ describe('the runtime verbs', () => {
|
||||
expect(next.supervisor.start).not.toHaveBeenCalled();
|
||||
|
||||
const started = await h.service.runtime({ action: 'start' });
|
||||
expect(started.ok && started.view).toMatchObject({ running: true, actions: ['start-session', 'stop', 'remove'] });
|
||||
expect(started.ok && started.view).toMatchObject({
|
||||
running: true,
|
||||
actions: ['start-session', 'stop', 'install', 'remove'],
|
||||
});
|
||||
expect(h.store.get().runtime?.enabled).toBe(true);
|
||||
});
|
||||
|
||||
@@ -1747,10 +1768,13 @@ describe('Remove', () => {
|
||||
unpackedGb: '0.6',
|
||||
},
|
||||
folder: '/data/local-model',
|
||||
library: [],
|
||||
// Measured again after the Remove: the store is empty here.
|
||||
store: { bytes: 0, gb: '0.0' },
|
||||
actions: ['install'],
|
||||
});
|
||||
expect(h.store.get()).toMatchObject({ schema: 1, state: 'idle' });
|
||||
for (const key of ['model', 'cli', 'runtime', 'connection', 'loaded'])
|
||||
for (const key of ['model', 'cli', 'runtime', 'connection', 'loaded', 'library'])
|
||||
expect(h.store.get()).not.toHaveProperty(key);
|
||||
expect(h.deps.audit).toHaveBeenCalledWith({
|
||||
admin: 'root',
|
||||
@@ -2342,7 +2366,7 @@ describe('an installed model whose runtime does not start', () => {
|
||||
h.world.startResult = { ok: false, code: 'runtime-start-failed', detail: 'spawn ENOENT' };
|
||||
await h.service.boot();
|
||||
const view = await h.service.view();
|
||||
expect(view).toMatchObject({ state: 'installed', running: false, actions: ['start', 'remove'] });
|
||||
expect(view).toMatchObject({ state: 'installed', running: false, actions: ['start', 'install', 'remove'] });
|
||||
expect(view.error).toEqual({
|
||||
code: 'runtime-start-failed',
|
||||
message: { text: 'The runtime did not start on this Mac.' },
|
||||
@@ -2396,7 +2420,11 @@ describe('a later build with another runtime pin', () => {
|
||||
// The stored version stays what runs, so the next start finds it again.
|
||||
expect(h.store.get().runtime?.version).toBe(OLD);
|
||||
const view = await h.service.view();
|
||||
expect(view).toMatchObject({ state: 'installed', running: true, actions: ['start-session', 'stop', 'remove'] });
|
||||
expect(view).toMatchObject({
|
||||
state: 'installed',
|
||||
running: true,
|
||||
actions: ['start-session', 'stop', 'install', 'remove'],
|
||||
});
|
||||
expect(view.error).toBeUndefined();
|
||||
// A NEW install would still download the pinned build.
|
||||
expect(view.runtime).toMatchObject({ version: OLLAMA_RUNTIME_PIN.version, installed: false });
|
||||
@@ -2639,3 +2667,688 @@ describe('Stop and "Use another port" while live sessions use the model', () =>
|
||||
expect(h.store.get().runtime?.port).toBe(11435);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------- The library ----------
|
||||
|
||||
const GPT = { id: 'gpt-oss:20b', label: 'GPT-OSS 20B', context: 131072 };
|
||||
const GPT_BYTES = 13_793_441_427;
|
||||
const QWEN_ENTRY = {
|
||||
id: 'qwen3.6:35b-a3b',
|
||||
label: 'Qwen3.6 35B-A3B',
|
||||
context: 131072,
|
||||
digest: DIGEST,
|
||||
bytes: PULL_BYTES,
|
||||
cli: { id: 'opencode', label: 'OpenCode' },
|
||||
};
|
||||
const GPT_ENTRY = {
|
||||
id: GPT.id,
|
||||
label: GPT.label,
|
||||
context: 65536,
|
||||
digest: DIGEST,
|
||||
bytes: GPT_BYTES,
|
||||
cli: { id: 'opencode', label: 'OpenCode' },
|
||||
};
|
||||
/** Two checked models, the Qwen one in use. */
|
||||
const TWO_MODELS: LocalModelStateFile = { ...INSTALLED, library: [QWEN_ENTRY, GPT_ENTRY] };
|
||||
const BOTH_TAGS = [
|
||||
{ name: QWEN_ENTRY.id, size: PULL_BYTES, digest: DIGEST },
|
||||
{ name: GPT_ENTRY.id, size: GPT_BYTES, digest: DIGEST },
|
||||
];
|
||||
const ADD_GPT: LocalModelInstallRequest = {
|
||||
modelId: GPT.id,
|
||||
cli: 'opencode',
|
||||
context: GPT.context,
|
||||
acknowledgedBytes: GPT_BYTES + RUNTIME_BYTES,
|
||||
};
|
||||
|
||||
describe('the first install starts the library', () => {
|
||||
it('the model that passed its check is the one checked model, with its digest, size and coding agent', async () => {
|
||||
const h = setup();
|
||||
await installAndWait(h.service);
|
||||
expect(h.store.get().library).toEqual([{ ...QWEN_ENTRY, verifiedAt: new Date(1_000_000).toISOString() }]);
|
||||
const view = await h.service.view();
|
||||
expect(view.library).toEqual([
|
||||
{
|
||||
id: QWEN_ENTRY.id,
|
||||
label: QWEN_ENTRY.label,
|
||||
context: 131072,
|
||||
sizeBytes: PULL_BYTES,
|
||||
sizeGb: '23.1',
|
||||
active: true,
|
||||
verified: true,
|
||||
status: 'ready',
|
||||
actions: [],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('the entry is written when the check passes, before the coding agent is installed', async () => {
|
||||
const h = setup({
|
||||
overrides: {
|
||||
runInstallCommand: vi.fn(async () => ({ code: 1, output: 'no network\n', timedOut: false, aborted: false })),
|
||||
},
|
||||
});
|
||||
await installAndWait(h.service);
|
||||
expect(h.store.get()).toMatchObject({ state: 'failed', step: 'agent', library: [{ id: QWEN_ENTRY.id }] });
|
||||
// It is the model of the stopped job: Resume goes on with it, so no switch is offered.
|
||||
const view = await h.service.view();
|
||||
expect(view.library.map((row) => row.actions)).toEqual([[]]);
|
||||
expect(view.actions).toEqual(['resume', 'install', 'remove']);
|
||||
});
|
||||
|
||||
it('a size the runtime does not report falls back to what the pull counted', async () => {
|
||||
const h = setup();
|
||||
h.world.tags = [];
|
||||
await installAndWait(h.service);
|
||||
expect(h.store.get().library?.[0].bytes).toBe(PULL_BYTES);
|
||||
});
|
||||
});
|
||||
|
||||
describe('adding a model to an installed one', () => {
|
||||
it('downloads, checks and connects it, and keeps the earlier model in the library', async () => {
|
||||
const h = setup({ state: INSTALLED, runtimeOnDisk: true, agentInstalled: true });
|
||||
await h.service.boot();
|
||||
h.calls.length = 0;
|
||||
h.world.tags = BOTH_TAGS;
|
||||
|
||||
const started = await h.service.install(ADD_GPT, ADMIN);
|
||||
expect(started.ok && started.view.state).toBe('installing');
|
||||
await h.service.idle();
|
||||
|
||||
expect(h.calls).toEqual([
|
||||
'ensureRuntimeInstalled',
|
||||
'supervisor.start',
|
||||
'supervisor.start',
|
||||
'pullModel',
|
||||
'supervisor.start',
|
||||
'verifyModel',
|
||||
'supervisor.start',
|
||||
'connectLocalModel',
|
||||
]);
|
||||
// Nothing of the earlier model was deleted: it was checked, and it stays.
|
||||
expect(h.deps.deleteModel).not.toHaveBeenCalled();
|
||||
expect(h.deps.removePartialModelBlobs).not.toHaveBeenCalled();
|
||||
// The runtime is started for the new model's context.
|
||||
expect(h.supervisor.start).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ port: 11435, contextLength: 131072 })
|
||||
);
|
||||
// The one endpoint record is rewritten for the new model; it stays ours.
|
||||
expect(h.deps.connectLocalModel).toHaveBeenCalledWith({
|
||||
port: 11435,
|
||||
modelId: GPT.id,
|
||||
context: GPT.context,
|
||||
ours: true,
|
||||
flagWasEnabled: false,
|
||||
});
|
||||
expect(h.store.get()).toMatchObject({
|
||||
state: 'installed',
|
||||
model: { id: GPT.id, context: GPT.context, digest: DIGEST },
|
||||
connection: { endpointId: 'local-ollama', flagWasEnabled: false },
|
||||
library: [QWEN_ENTRY, { id: GPT.id, context: GPT.context, digest: DIGEST, bytes: GPT_BYTES }],
|
||||
});
|
||||
expect(h.store.get()).not.toHaveProperty('replacedModelId');
|
||||
|
||||
const view = await h.service.view();
|
||||
expect(view.model).toEqual({ id: GPT.id, label: GPT.label });
|
||||
expect(view.library.map((row) => [row.id, row.active, row.status, row.actions])).toEqual([
|
||||
[GPT.id, true, 'ready', []],
|
||||
[QWEN_ENTRY.id, false, 'ready', ['use', 'delete']],
|
||||
]);
|
||||
expect(view.actions).toEqual(['start-session', 'stop', 'install', 'use', 'delete-model', 'remove']);
|
||||
// Only the model in use may be launched.
|
||||
expect(await h.service.launchRefusalMessage('local-ollama', GPT.id)).toBeUndefined();
|
||||
expect(await h.service.launchRefusalMessage('local-ollama', QWEN_ENTRY.id)).toBe(
|
||||
'Only GPT-OSS 20B is in use on this Mac. Switch models in the local model window first.'
|
||||
);
|
||||
});
|
||||
|
||||
it('is refused while live sessions use the model, before anything is asked or started', async () => {
|
||||
const h = setup({ state: INSTALLED, runtimeOnDisk: true, agentInstalled: true });
|
||||
await h.service.boot();
|
||||
h.calls.length = 0;
|
||||
const refused = await h.service.install(ADD_GPT, ADMIN, ['w1-app', 'w2-api']);
|
||||
expect(refused).toMatchObject({
|
||||
ok: false,
|
||||
status: 409,
|
||||
conflict: {
|
||||
reason: 'sessions-open',
|
||||
sessions: ['w1-app', 'w2-api'],
|
||||
message: { text: LOCAL_MODEL_INSTALL_LABELS.conflictSessionsOpen, vars: { sessions: 'w1-app, w2-api' } },
|
||||
},
|
||||
});
|
||||
expect(h.calls).toEqual([]);
|
||||
expect(h.deps.readHardware).not.toHaveBeenCalled();
|
||||
expect(h.store.get().state).toBe('installed');
|
||||
});
|
||||
|
||||
it('a first install is not held up by sessions elsewhere: no connection of ours exists yet', async () => {
|
||||
const h = setup();
|
||||
const started = await h.service.install(REQUEST, ADMIN, ['w1-app']);
|
||||
expect(started.ok).toBe(true);
|
||||
await h.service.cancel();
|
||||
});
|
||||
|
||||
it('the model in use at ANOTHER context is an install like any other: it is checked again at that context', async () => {
|
||||
const h = setup({ state: INSTALLED, runtimeOnDisk: true, agentInstalled: true });
|
||||
h.deps.recommend.mockReturnValue({
|
||||
...recommendation(true),
|
||||
pick: candidate('qwen3.6:35b-a3b', 'Qwen3.6 35B-A3B', 65536, PULL_BYTES, true),
|
||||
});
|
||||
await h.service.boot();
|
||||
const started = await h.service.install({ ...REQUEST, context: 65536 }, ADMIN);
|
||||
expect(started.ok).toBe(true);
|
||||
await h.service.idle();
|
||||
expect(h.store.get()).toMatchObject({
|
||||
state: 'installed',
|
||||
model: { id: 'qwen3.6:35b-a3b', context: 65536 },
|
||||
library: [{ id: 'qwen3.6:35b-a3b', context: 65536 }],
|
||||
});
|
||||
expect(h.store.get().library).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('a job that stops leaves the earlier model to go back to, and no session until then', async () => {
|
||||
const h = setup({ state: INSTALLED, runtimeOnDisk: true, agentInstalled: true });
|
||||
await h.service.boot();
|
||||
h.world.tags = BOTH_TAGS;
|
||||
h.deps.verifyModel.mockResolvedValueOnce({ ok: false, code: 'no-tool-call' } as never);
|
||||
await h.service.install(ADD_GPT, ADMIN);
|
||||
await h.service.idle();
|
||||
|
||||
expect(h.store.get()).toMatchObject({ state: 'failed', step: 'verify', model: { id: GPT.id } });
|
||||
// The endpoint record still names the earlier model, and nothing may be started on it.
|
||||
expect(await h.service.launchRefusal('local-ollama')).toEqual({
|
||||
code: 'job-unfinished',
|
||||
message: { text: LOCAL_MODEL_INSTALL_LABELS.errJobUnfinished },
|
||||
next: 'retry',
|
||||
});
|
||||
expect(await h.service.launchRefusalMessage('local-ollama', QWEN_ENTRY.id)).toBe(
|
||||
'The model on this Mac is being changed and is not ready. Finish or undo that in the local model window first.'
|
||||
);
|
||||
const view = await h.service.view();
|
||||
expect(view.actions).toEqual(['resume', 'install', 'use', 'delete-model', 'remove']);
|
||||
expect(view.library.map((row) => [row.id, row.status, row.actions])).toEqual([
|
||||
[QWEN_ENTRY.id, 'ready', ['use', 'delete']],
|
||||
// Downloaded and not checked: the model of the stopped job, so nothing is offered on it.
|
||||
[GPT.id, 'unverified', []],
|
||||
]);
|
||||
|
||||
// The way back. The model that never passed its check goes: it is what the job left behind.
|
||||
h.calls.length = 0;
|
||||
const back = await h.service.use({ modelId: QWEN_ENTRY.id }, ADMIN);
|
||||
expect(back.ok).toBe(true);
|
||||
await h.service.idle();
|
||||
expect(h.deps.deleteModel).toHaveBeenCalledWith(expect.anything(), GPT.id, expect.anything());
|
||||
expect(h.calls).toContain('removePartialModelBlobs');
|
||||
expect(h.store.get()).toMatchObject({ state: 'installed', model: { id: QWEN_ENTRY.id, digest: DIGEST } });
|
||||
expect(h.store.get()).not.toHaveProperty('replacedModelId');
|
||||
expect(h.store.get()).not.toHaveProperty('error');
|
||||
expect(await h.service.launchRefusalMessage('local-ollama', QWEN_ENTRY.id)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('use: the switch to a checked model', () => {
|
||||
it('loads and checks it again, connects it, and downloads nothing', async () => {
|
||||
const h = setup({ state: TWO_MODELS, runtimeOnDisk: true, agentInstalled: true });
|
||||
await h.service.boot();
|
||||
h.calls.length = 0;
|
||||
h.world.tags = BOTH_TAGS;
|
||||
|
||||
const started = await h.service.use({ modelId: GPT.id }, ADMIN);
|
||||
expect(started.ok && started.view).toMatchObject({
|
||||
state: 'installing',
|
||||
step: 'verify',
|
||||
steps: ['verify', 'connect'],
|
||||
model: { id: GPT.id, label: GPT.label },
|
||||
context: 65536,
|
||||
});
|
||||
await h.service.idle();
|
||||
|
||||
expect(h.calls).toEqual([
|
||||
'recheckCliInstalled',
|
||||
'supervisor.start',
|
||||
'verifyModel',
|
||||
'supervisor.start',
|
||||
'connectLocalModel',
|
||||
]);
|
||||
expect(h.deps.pullModel).not.toHaveBeenCalled();
|
||||
expect(h.deps.ensureRuntimeInstalled).not.toHaveBeenCalled();
|
||||
expect(h.deps.deleteModel).not.toHaveBeenCalled();
|
||||
// The runtime is restarted for the context the model was checked with.
|
||||
expect(h.supervisor.start).toHaveBeenLastCalledWith(expect.objectContaining({ contextLength: 65536 }));
|
||||
expect(h.deps.verifyModel).toHaveBeenCalledWith(expect.anything(), GPT.id, 65536, expect.anything());
|
||||
expect(h.deps.connectLocalModel).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ modelId: GPT.id, context: 65536, ours: true })
|
||||
);
|
||||
expect(h.store.get()).toMatchObject({
|
||||
state: 'installed',
|
||||
model: { id: GPT.id, context: 65536, digest: DIGEST },
|
||||
cli: { id: 'opencode', label: 'OpenCode' },
|
||||
library: [QWEN_ENTRY, { id: GPT.id, digest: DIGEST }],
|
||||
});
|
||||
expect(h.deps.audit).toHaveBeenCalledWith({
|
||||
admin: 'root',
|
||||
action: 'local_model_use',
|
||||
target: GPT.id,
|
||||
ip: '127.0.0.1',
|
||||
detail: { cli: 'opencode', context: 65536 },
|
||||
});
|
||||
});
|
||||
|
||||
it('is refused for a model that is not one of the checked ones, and for the one in use', async () => {
|
||||
const h = setup({ state: TWO_MODELS, runtimeOnDisk: true, agentInstalled: true });
|
||||
await h.service.boot();
|
||||
h.calls.length = 0;
|
||||
expect(await h.service.use({ modelId: 'someone-else:7b' }, ADMIN)).toEqual({
|
||||
ok: false,
|
||||
status: 400,
|
||||
message: { text: LOCAL_MODEL_INSTALL_LABELS.refusedNotInLibrary },
|
||||
});
|
||||
expect(await h.service.use({ modelId: QWEN_ENTRY.id }, ADMIN)).toMatchObject({
|
||||
ok: false,
|
||||
status: 409,
|
||||
conflict: { reason: 'already-installed' },
|
||||
});
|
||||
expect(h.calls).toEqual([]);
|
||||
expect(h.store.get().state).toBe('installed');
|
||||
});
|
||||
|
||||
it('is refused while live sessions use the model', async () => {
|
||||
const h = setup({ state: TWO_MODELS, runtimeOnDisk: true, agentInstalled: true });
|
||||
await h.service.boot();
|
||||
h.calls.length = 0;
|
||||
expect(await h.service.use({ modelId: GPT.id }, ADMIN, ['w1-app'])).toMatchObject({
|
||||
ok: false,
|
||||
status: 409,
|
||||
conflict: { reason: 'sessions-open', sessions: ['w1-app'] },
|
||||
});
|
||||
expect(h.calls).toEqual([]);
|
||||
});
|
||||
|
||||
it('is 409 wrong-state where there is nothing to switch: idle', async () => {
|
||||
const h = setup();
|
||||
expect(await h.service.use({ modelId: GPT.id }, ADMIN)).toMatchObject({
|
||||
ok: false,
|
||||
status: 409,
|
||||
conflict: { reason: 'wrong-state' },
|
||||
});
|
||||
});
|
||||
|
||||
it('a model whose digest is no longer the stored one is not loaded: model-changed, and the way back stays', async () => {
|
||||
const h = setup({ state: TWO_MODELS, runtimeOnDisk: true, agentInstalled: true });
|
||||
await h.service.boot();
|
||||
h.world.tags = [BOTH_TAGS[0], { ...BOTH_TAGS[1], digest: 'ffff00000000' }];
|
||||
h.deps.readModelDigest.mockImplementation(async (_target: unknown, id: string) =>
|
||||
id === GPT.id ? 'ffff00000000' : DIGEST
|
||||
);
|
||||
h.calls.length = 0;
|
||||
await h.service.use({ modelId: GPT.id }, ADMIN);
|
||||
await h.service.idle();
|
||||
|
||||
expect(h.deps.verifyModel).not.toHaveBeenCalled();
|
||||
expect(h.deps.connectLocalModel).not.toHaveBeenCalled();
|
||||
const view = await h.service.view();
|
||||
expect(view.state).toBe('failed');
|
||||
expect(view.error).toMatchObject({
|
||||
code: 'model-changed',
|
||||
detail: 'The runtime reports another digest for the model.',
|
||||
});
|
||||
expect(view.library.map((row) => [row.id, row.status, row.actions])).toEqual([
|
||||
[QWEN_ENTRY.id, 'ready', ['use', 'delete']],
|
||||
[GPT.id, 'changed', []],
|
||||
]);
|
||||
// The changed model stays a library entry until the person deletes it; it is never deleted as a leftover.
|
||||
await h.service.use({ modelId: QWEN_ENTRY.id }, ADMIN);
|
||||
await h.service.idle();
|
||||
expect(h.deps.deleteModel).not.toHaveBeenCalled();
|
||||
expect(h.store.get()).toMatchObject({ state: 'installed', model: { id: QWEN_ENTRY.id } });
|
||||
});
|
||||
|
||||
it('a model the runtime no longer lists is model-changed too, said as that', async () => {
|
||||
const h = setup({ state: TWO_MODELS, runtimeOnDisk: true, agentInstalled: true });
|
||||
await h.service.boot();
|
||||
h.deps.readModelDigest.mockImplementation(async (_target: unknown, id: string) =>
|
||||
id === GPT.id ? undefined : DIGEST
|
||||
);
|
||||
await h.service.use({ modelId: GPT.id }, ADMIN);
|
||||
await h.service.idle();
|
||||
expect((await h.service.view()).error).toMatchObject({
|
||||
code: 'model-changed',
|
||||
detail: 'The runtime no longer lists the model.',
|
||||
});
|
||||
});
|
||||
|
||||
it('takes the coding agent the model was checked with when it is installed, else the one of the state', async () => {
|
||||
const pi = { id: 'pi', label: 'Pi' };
|
||||
const state: LocalModelStateFile = { ...TWO_MODELS, library: [QWEN_ENTRY, { ...GPT_ENTRY, cli: pi }] };
|
||||
const withPi = setup({ state, runtimeOnDisk: true });
|
||||
withPi.deps.recheckCliInstalled.mockImplementation(async () => true);
|
||||
await withPi.service.boot();
|
||||
await withPi.service.use({ modelId: GPT.id }, ADMIN);
|
||||
await withPi.service.idle();
|
||||
expect(withPi.store.get().cli).toEqual(pi);
|
||||
|
||||
const withoutPi = setup({ state, runtimeOnDisk: true });
|
||||
withoutPi.deps.recheckCliInstalled.mockImplementation((async (id: string) => id === 'opencode') as never);
|
||||
await withoutPi.service.boot();
|
||||
await withoutPi.service.use({ modelId: GPT.id }, ADMIN);
|
||||
await withoutPi.service.idle();
|
||||
expect(withoutPi.store.get().cli).toEqual({ id: 'opencode', label: 'OpenCode' });
|
||||
});
|
||||
|
||||
it('with no coding agent of the two installed it is refused, in one sentence, and nothing starts', async () => {
|
||||
const h = setup({ state: TWO_MODELS, runtimeOnDisk: true, agentInstalled: false });
|
||||
await h.service.boot();
|
||||
expect(await h.service.use({ modelId: GPT.id }, ADMIN)).toEqual({
|
||||
ok: false,
|
||||
status: 400,
|
||||
message: { text: LOCAL_MODEL_INSTALL_LABELS.refusedAgentMissing, vars: { cli: 'OpenCode' } },
|
||||
});
|
||||
expect(h.store.get().state).toBe('installed');
|
||||
});
|
||||
|
||||
it('a switch or a new model from a stopped model starts the runtime and undoes the Stop', async () => {
|
||||
const stopped: LocalModelStateFile = { ...TWO_MODELS, runtime: { ...INSTALLED.runtime!, enabled: false } };
|
||||
const switched = setup({ state: stopped, runtimeOnDisk: true, agentInstalled: true });
|
||||
await switched.service.boot();
|
||||
expect(switched.supervisor.start).not.toHaveBeenCalled();
|
||||
await switched.service.use({ modelId: GPT.id }, ADMIN);
|
||||
await switched.service.idle();
|
||||
// The model is in use and running now, so it starts with the server again, like after Start.
|
||||
expect(switched.store.get()).toMatchObject({ state: 'installed', runtime: { enabled: true } });
|
||||
expect((await switched.service.view()).running).toBe(true);
|
||||
|
||||
const added = setup({ state: { ...INSTALLED, runtime: { ...INSTALLED.runtime!, enabled: false } } });
|
||||
added.world.runtimeOnDisk = true;
|
||||
added.world.agentInstalled = true;
|
||||
await added.service.boot();
|
||||
await added.service.install(ADD_GPT, ADMIN);
|
||||
await added.service.idle();
|
||||
expect(added.store.get()).toMatchObject({ state: 'installed', model: { id: GPT.id }, runtime: { enabled: true } });
|
||||
|
||||
// A switch that was refused changed nothing: the model stays stopped.
|
||||
const refused = setup({ state: stopped, runtimeOnDisk: true, agentInstalled: true });
|
||||
await refused.service.use({ modelId: 'someone-else:7b' }, ADMIN);
|
||||
expect(refused.store.get().runtime?.enabled).toBe(false);
|
||||
});
|
||||
|
||||
it('Cancel pauses a switch like any job, and Resume goes on with it', async () => {
|
||||
const h = setup({
|
||||
state: TWO_MODELS,
|
||||
runtimeOnDisk: true,
|
||||
agentInstalled: true,
|
||||
overrides: {
|
||||
verifyModel: vi
|
||||
.fn()
|
||||
.mockImplementationOnce(async (_t: unknown, _id: string, _c: number, signal?: AbortSignal) => {
|
||||
await untilAborted(signal);
|
||||
return { ok: false as const, code: 'cancelled' as const };
|
||||
})
|
||||
.mockResolvedValue({
|
||||
ok: true as const,
|
||||
loaded: { context: 65536, sizeBytes: 15_000_000_000, sizeVramBytes: 15_000_000_000 },
|
||||
}),
|
||||
},
|
||||
});
|
||||
await h.service.boot();
|
||||
await h.service.use({ modelId: GPT.id }, ADMIN);
|
||||
await vi.waitFor(() => expect(h.deps.verifyModel).toHaveBeenCalled());
|
||||
const paused = await h.service.cancel();
|
||||
expect(paused.ok && paused.view).toMatchObject({ state: 'paused', step: 'verify', steps: ['verify', 'connect'] });
|
||||
await h.service.resume({}, ADMIN);
|
||||
await h.service.idle();
|
||||
expect(h.store.get()).toMatchObject({ state: 'installed', model: { id: GPT.id } });
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteLibraryModel: one model out of the store', () => {
|
||||
async function twoModelsRunning() {
|
||||
const h = setup({ state: TWO_MODELS, runtimeOnDisk: true, agentInstalled: true });
|
||||
await h.service.boot();
|
||||
h.world.tags = [...BOTH_TAGS];
|
||||
h.deps.deleteModel.mockImplementation(async (_target: unknown, id: string) => {
|
||||
h.calls.push('deleteModel');
|
||||
h.world.tags = (h.world.tags ?? []).filter((tag) => tag.name !== id);
|
||||
return true;
|
||||
});
|
||||
h.calls.length = 0;
|
||||
return h;
|
||||
}
|
||||
|
||||
it('deletes it through the runtime, drops its entry and writes the audit line', async () => {
|
||||
const h = await twoModelsRunning();
|
||||
const result = await h.service.deleteLibraryModel({ modelId: GPT.id }, ADMIN);
|
||||
expect(h.deps.deleteModel).toHaveBeenCalledTimes(1);
|
||||
expect(h.deps.deleteModel).toHaveBeenCalledWith({ baseUrl: 'http://127.0.0.1:11435' }, GPT.id, expect.anything());
|
||||
expect(h.store.get().library).toEqual([QWEN_ENTRY]);
|
||||
expect(h.store.get().state).toBe('installed');
|
||||
// The answer is the view after the delete: the runtime was asked again.
|
||||
expect(result.ok && result.view.library.map((row) => row.id)).toEqual([QWEN_ENTRY.id]);
|
||||
expect(h.deps.audit).toHaveBeenCalledWith({
|
||||
admin: 'root',
|
||||
action: 'local_model_delete',
|
||||
target: GPT.id,
|
||||
ip: '127.0.0.1',
|
||||
detail: { verified: true, bytes: GPT_BYTES },
|
||||
});
|
||||
// Nothing else of the feature was touched.
|
||||
expect(h.calls).toEqual(['deleteModel']);
|
||||
});
|
||||
|
||||
it('refuses the model in use: switch first, or remove', async () => {
|
||||
const h = await twoModelsRunning();
|
||||
expect(await h.service.deleteLibraryModel({ modelId: QWEN_ENTRY.id }, ADMIN)).toEqual({
|
||||
ok: false,
|
||||
status: 409,
|
||||
conflict: {
|
||||
reason: 'model-active',
|
||||
message: { text: LOCAL_MODEL_INSTALL_LABELS.conflictModelActive, vars: { model: 'Qwen3.6 35B-A3B' } },
|
||||
},
|
||||
});
|
||||
expect(h.deps.deleteModel).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('refuses while the runtime is down or does not answer: only the runtime deletes a model', async () => {
|
||||
const stopped = setup({ state: TWO_MODELS });
|
||||
expect(await stopped.service.deleteLibraryModel({ modelId: GPT.id }, ADMIN)).toMatchObject({
|
||||
ok: false,
|
||||
status: 409,
|
||||
conflict: { reason: 'runtime-not-running', message: { text: LOCAL_MODEL_INSTALL_LABELS.errRuntimeNotRunning } },
|
||||
});
|
||||
const silent = await twoModelsRunning();
|
||||
silent.deps.getRuntimeVersion.mockResolvedValue(undefined);
|
||||
expect(await silent.service.deleteLibraryModel({ modelId: GPT.id }, ADMIN)).toMatchObject({
|
||||
ok: false,
|
||||
status: 409,
|
||||
conflict: { reason: 'runtime-not-running' },
|
||||
});
|
||||
expect(silent.deps.deleteModel).not.toHaveBeenCalled();
|
||||
expect(silent.store.get().library).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('refuses a name that is neither a checked model nor one the runtime lists, and anything that is not name:tag', async () => {
|
||||
const h = await twoModelsRunning();
|
||||
const refused = { ok: false, status: 400, message: { text: LOCAL_MODEL_INSTALL_LABELS.refusedNotOnThisMac } };
|
||||
expect(await h.service.deleteLibraryModel({ modelId: 'someone-else:7b' }, ADMIN)).toEqual(refused);
|
||||
for (const modelId of ['../../etc/passwd', '/Users/me/models', 'hf.co/someone/model:Q4', 'NoTag', '']) {
|
||||
expect(await h.service.deleteLibraryModel({ modelId }, ADMIN)).toEqual(refused);
|
||||
}
|
||||
expect(h.deps.deleteModel).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('is 409 wrong-state in idle: there is no runtime to ask', async () => {
|
||||
const h = setup();
|
||||
expect(await h.service.deleteLibraryModel({ modelId: GPT.id }, ADMIN)).toMatchObject({
|
||||
ok: false,
|
||||
status: 409,
|
||||
conflict: { reason: 'wrong-state' },
|
||||
});
|
||||
});
|
||||
|
||||
it('a runtime that does not delete it: said as that, and the entry stays', async () => {
|
||||
const h = await twoModelsRunning();
|
||||
h.deps.deleteModel.mockResolvedValue(false);
|
||||
expect(await h.service.deleteLibraryModel({ modelId: GPT.id }, ADMIN)).toEqual({
|
||||
ok: false,
|
||||
status: 500,
|
||||
message: { text: LOCAL_MODEL_INSTALL_LABELS.deleteFailed, vars: { model: 'GPT-OSS 20B' } },
|
||||
});
|
||||
expect(h.store.get().library).toEqual([QWEN_ENTRY, GPT_ENTRY]);
|
||||
expect(h.deps.audit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('a model the runtime lists and Codeman never checked can be deleted too', async () => {
|
||||
const h = await twoModelsRunning();
|
||||
h.world.tags = [...BOTH_TAGS, { name: 'leftover:9b', size: 6_000_000_000, digest: 'aa' }];
|
||||
const before = await h.service.view();
|
||||
expect(before.library.find((row) => row.id === 'leftover:9b')).toMatchObject({
|
||||
status: 'unverified',
|
||||
sizeGb: '6.0',
|
||||
actions: ['delete'],
|
||||
});
|
||||
const result = await h.service.deleteLibraryModel({ modelId: 'leftover:9b' }, ADMIN);
|
||||
expect(result.ok && result.view.library.map((row) => row.id)).toEqual([QWEN_ENTRY.id, GPT.id]);
|
||||
expect(h.store.get().library).toEqual([QWEN_ENTRY, GPT_ENTRY]);
|
||||
expect(h.deps.audit).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ action: 'local_model_delete', detail: { verified: false, bytes: 6_000_000_000 } })
|
||||
);
|
||||
});
|
||||
|
||||
it('a checked model the runtime no longer lists: its entry is dropped once the runtime says it is gone', async () => {
|
||||
const h = await twoModelsRunning();
|
||||
h.world.tags = [BOTH_TAGS[0]];
|
||||
const result = await h.service.deleteLibraryModel({ modelId: GPT.id }, ADMIN);
|
||||
expect(result.ok).toBe(true);
|
||||
// Asked of the runtime all the same: its "not found" is what says the model is gone.
|
||||
expect(h.deps.deleteModel).toHaveBeenCalledWith(expect.anything(), GPT.id, expect.anything());
|
||||
expect(h.store.get().library).toEqual([QWEN_ENTRY]);
|
||||
});
|
||||
|
||||
it('works from a stopped job too, for any model but the one of that job', async () => {
|
||||
const state: LocalModelStateFile = {
|
||||
...jobAt('verify', 'paused'),
|
||||
model: { ...GPT },
|
||||
library: [QWEN_ENTRY],
|
||||
connection: { endpointId: 'local-ollama', flagWasEnabled: false },
|
||||
};
|
||||
const h = setup({ state, runtimeOnDisk: true });
|
||||
h.world.running = true;
|
||||
h.world.tags = BOTH_TAGS;
|
||||
expect(await h.service.deleteLibraryModel({ modelId: GPT.id }, ADMIN)).toMatchObject({
|
||||
ok: false,
|
||||
conflict: { reason: 'model-active' },
|
||||
});
|
||||
const result = await h.service.deleteLibraryModel({ modelId: QWEN_ENTRY.id }, ADMIN);
|
||||
expect(result.ok).toBe(true);
|
||||
expect(h.store.get().library).toEqual([]);
|
||||
expect(h.store.get().state).toBe('paused');
|
||||
});
|
||||
});
|
||||
|
||||
describe('the model store in idle: what an earlier Remove kept', () => {
|
||||
it('shows what it takes, offers Remove, and Remove with the models frees it', async () => {
|
||||
const h = setup();
|
||||
h.world.storeBytes = PULL_BYTES;
|
||||
const view = await h.service.view();
|
||||
expect(view).toMatchObject({
|
||||
state: 'idle',
|
||||
library: [],
|
||||
store: { bytes: PULL_BYTES, gb: '23.1' },
|
||||
actions: ['install', 'remove'],
|
||||
});
|
||||
|
||||
h.deps.removeLocalModelFiles.mockImplementation(async () => {
|
||||
h.calls.push('removeLocalModelFiles');
|
||||
h.world.storeBytes = 0;
|
||||
});
|
||||
const removed = await h.service.remove({ removeModel: true }, [], ADMIN);
|
||||
expect(h.deps.removeLocalModelFiles).toHaveBeenCalledWith({ models: true, paths: PATHS });
|
||||
expect(removed.ok && removed.view).toMatchObject({
|
||||
state: 'idle',
|
||||
store: { bytes: 0, gb: '0.0' },
|
||||
actions: ['install'],
|
||||
});
|
||||
});
|
||||
|
||||
it('Remove that keeps the models leaves them there: the next view says how much', async () => {
|
||||
const h = setup({ state: TWO_MODELS, runtimeOnDisk: true });
|
||||
h.world.storeBytes = PULL_BYTES + GPT_BYTES;
|
||||
const removed = await h.service.remove({}, [], ADMIN);
|
||||
expect(removed.ok && removed.view).toMatchObject({
|
||||
state: 'idle',
|
||||
library: [],
|
||||
store: { bytes: PULL_BYTES + GPT_BYTES, gb: '36.9' },
|
||||
actions: ['install', 'remove'],
|
||||
});
|
||||
expect(h.store.get()).not.toHaveProperty('library');
|
||||
});
|
||||
});
|
||||
|
||||
describe('the view does not ask the runtime or the disk on every poll', () => {
|
||||
it('reads the list and the store once, then again only when they are stale', async () => {
|
||||
const h = setup({ state: TWO_MODELS, runtimeOnDisk: true });
|
||||
await h.service.boot();
|
||||
h.world.tags = BOTH_TAGS;
|
||||
h.deps.listTags.mockClear();
|
||||
for (let i = 0; i < 5; i++) await h.service.view();
|
||||
expect(h.deps.listTags).toHaveBeenCalledTimes(1);
|
||||
expect(h.deps.measureModelStore).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Five seconds on: the list is read again, in the background. The view does not wait for
|
||||
// a runtime that is slow to answer: it is built from the list it has.
|
||||
h.world.clock += 5_000;
|
||||
let answer: (tags: typeof BOTH_TAGS) => void = () => {};
|
||||
h.deps.listTags.mockImplementationOnce(() => new Promise((resolve) => (answer = resolve)));
|
||||
const stale = await h.service.view();
|
||||
expect(stale.library.map((row) => row.status)).toEqual(['ready', 'ready']);
|
||||
await vi.waitFor(() => expect(h.deps.listTags).toHaveBeenCalledTimes(2));
|
||||
// A second poll while that read is out starts no second one.
|
||||
await h.service.view();
|
||||
expect(h.deps.listTags).toHaveBeenCalledTimes(2);
|
||||
answer([BOTH_TAGS[0]]);
|
||||
await vi.waitFor(async () =>
|
||||
expect((await h.service.view()).library.map((row) => row.status)).toEqual(['ready', 'missing'])
|
||||
);
|
||||
expect(h.deps.measureModelStore).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Thirty seconds on: the store is measured again.
|
||||
h.world.clock += 30_000;
|
||||
await h.service.view();
|
||||
await vi.waitFor(() => expect(h.deps.measureModelStore).toHaveBeenCalledTimes(2));
|
||||
});
|
||||
|
||||
it('a runtime that does not answer keeps the last list: no model reads as missing for a moment', async () => {
|
||||
const h = setup({ state: TWO_MODELS, runtimeOnDisk: true });
|
||||
await h.service.boot();
|
||||
h.world.tags = BOTH_TAGS;
|
||||
await h.service.view();
|
||||
h.deps.getRuntimeVersion.mockResolvedValue(undefined);
|
||||
h.deps.listTags.mockClear();
|
||||
h.world.clock += 5_000;
|
||||
await h.service.view();
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
expect(h.deps.listTags).not.toHaveBeenCalled();
|
||||
expect((await h.service.view()).library.map((row) => row.status)).toEqual(['ready', 'ready']);
|
||||
});
|
||||
|
||||
it('a stopped runtime is not asked at all, and offers no delete', async () => {
|
||||
const h = setup({ state: { ...TWO_MODELS, runtime: { ...INSTALLED.runtime!, enabled: false } } });
|
||||
await h.service.boot();
|
||||
const view = await h.service.view();
|
||||
expect(h.deps.listTags).not.toHaveBeenCalled();
|
||||
expect(view.library.map((row) => [row.id, row.sizeGb, row.actions])).toEqual([
|
||||
[QWEN_ENTRY.id, '23.1', []],
|
||||
[GPT.id, '13.8', ['use']],
|
||||
]);
|
||||
});
|
||||
|
||||
it('a listing or a measurement that fails is not an error of the view', async () => {
|
||||
const h = setup({ state: TWO_MODELS, runtimeOnDisk: true });
|
||||
await h.service.boot();
|
||||
h.deps.listTags.mockRejectedValue(new Error('socket hang up'));
|
||||
h.deps.measureModelStore.mockRejectedValue(new Error('EIO'));
|
||||
const view = await h.service.view();
|
||||
expect(view.state).toBe('installed');
|
||||
expect(view).not.toHaveProperty('store');
|
||||
expect(view.library.map((row) => row.id)).toEqual([QWEN_ENTRY.id, GPT.id]);
|
||||
});
|
||||
});
|
||||
@@ -37,6 +37,7 @@ import {
|
||||
OLLAMA_RUNTIME_PIN,
|
||||
parseInferenceComputeTotal,
|
||||
removeLocalModelFiles,
|
||||
measureModelStore,
|
||||
removePartialModelBlobs,
|
||||
RUNTIME_CTX_CHECKPOINTS,
|
||||
RUNTIME_DOWNLOAD_RETRIES,
|
||||
@@ -1103,3 +1104,56 @@ describe('removePartialModelBlobs', () => {
|
||||
expect(existsSync(join(outside, 'sha256-eee-partial'))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('measureModelStore', () => {
|
||||
const blob = (name: string): string => join(paths.modelsDir, 'blobs', name);
|
||||
|
||||
it('is the sizes of the files in the blob folder, a download that was interrupted included', async () => {
|
||||
await mkdir(join(paths.modelsDir, 'blobs'), { recursive: true });
|
||||
await mkdir(join(paths.modelsDir, 'manifests', 'registry.ollama.ai'), { recursive: true });
|
||||
await writeFile(blob('sha256-aaa'), 'x'.repeat(1000));
|
||||
await writeFile(blob('sha256-bbb'), 'y'.repeat(250));
|
||||
await writeFile(blob('sha256-ccc-partial'), 'z'.repeat(40));
|
||||
// The manifests are a few kilobytes beside gigabytes of layers: left out.
|
||||
await writeFile(join(paths.modelsDir, 'manifests', 'registry.ollama.ai', 'latest'), '{"layers":[]}');
|
||||
expect(await measureModelStore(paths)).toBe(1290);
|
||||
});
|
||||
|
||||
it('is 0 without a model store, without a blob folder and for an empty one, and never throws', async () => {
|
||||
expect(await measureModelStore(paths)).toBe(0);
|
||||
await mkdir(paths.modelsDir, { recursive: true });
|
||||
expect(await measureModelStore(paths)).toBe(0);
|
||||
await mkdir(join(paths.modelsDir, 'blobs'), { recursive: true });
|
||||
expect(await measureModelStore(paths)).toBe(0);
|
||||
});
|
||||
|
||||
it('reads nothing and changes nothing: the store is as it was afterwards', async () => {
|
||||
await mkdir(join(paths.modelsDir, 'blobs'), { recursive: true });
|
||||
await writeFile(blob('sha256-aaa'), 'weights');
|
||||
await writeFile(blob('sha256-bbb-partial'), 'half');
|
||||
await measureModelStore(paths);
|
||||
expect(await readFile(blob('sha256-aaa'), 'utf8')).toBe('weights');
|
||||
expect(await readFile(blob('sha256-bbb-partial'), 'utf8')).toBe('half');
|
||||
});
|
||||
|
||||
it('counts only regular files directly in the blob folder, and follows no link', async () => {
|
||||
const outside = await outsideDir();
|
||||
await writeFile(join(outside, 'huge'), 'h'.repeat(5000));
|
||||
await mkdir(join(paths.modelsDir, 'blobs', 'a-folder'), { recursive: true });
|
||||
await writeFile(join(paths.modelsDir, 'blobs', 'a-folder', 'inside'), 'i'.repeat(700));
|
||||
await symlink(join(outside, 'huge'), blob('sha256-link'));
|
||||
await writeFile(blob('sha256-aaa'), 'x'.repeat(10));
|
||||
// The folder's file and the link's target are somebody else's: not counted.
|
||||
expect(await measureModelStore(paths)).toBe(10);
|
||||
});
|
||||
|
||||
it('a blob folder, a model store or a root that is a symlink counts as empty', async () => {
|
||||
const outside = await outsideDir();
|
||||
await writeFile(join(outside, 'sha256-eee'), 'e'.repeat(900));
|
||||
await mkdir(paths.modelsDir, { recursive: true });
|
||||
await symlink(outside, join(paths.modelsDir, 'blobs'));
|
||||
expect(await measureModelStore(paths)).toBe(0);
|
||||
// A path that is not inside the local model folder is refused before anything is read.
|
||||
expect(await measureModelStore({ ...paths, modelsDir: outside })).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -196,6 +196,89 @@ describe('parseLocalModelState', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseLocalModelState: the library', () => {
|
||||
const QWEN = {
|
||||
id: 'qwen3.6:35b-a3b',
|
||||
label: 'Qwen3.6 35B-A3B',
|
||||
context: 131072,
|
||||
digest: '0ce5ff81f9ffdf89dd8192ca7b5d34cefd8996a875b3aaaaafaf5c4813569eba',
|
||||
bytes: 23_105_798_956,
|
||||
cli: { id: 'opencode', label: 'OpenCode' },
|
||||
verifiedAt: '2026-11-10T09:00:00.000Z',
|
||||
};
|
||||
const MIMO = { id: 'mimo-v2.6:9b-q4_k_m', label: 'MiMo V2.6 9B', context: 65536, digest: 'abc123' };
|
||||
const read = (state: unknown) => parseLocalModelState(JSON.stringify(state));
|
||||
|
||||
it('reads back the checked models of an installed state and of a job, in their order', () => {
|
||||
expect(read({ ...INSTALLED, library: [QWEN, MIMO] })).toEqual({ ...INSTALLED, library: [QWEN, MIMO] });
|
||||
expect(read({ ...PAUSED, library: [MIMO] })).toEqual({ ...PAUSED, library: [MIMO] });
|
||||
expect(read({ ...FAILED, library: [MIMO, QWEN] }).library).toEqual([MIMO, QWEN]);
|
||||
});
|
||||
|
||||
it('a file an earlier build wrote has no list, and reads back without one', () => {
|
||||
expect(read(INSTALLED)).toEqual(INSTALLED);
|
||||
expect(read(INSTALLED)).not.toHaveProperty('library');
|
||||
expect(read(PAUSED)).not.toHaveProperty('library');
|
||||
});
|
||||
|
||||
it('an empty list stays an empty list: every checked model was deleted', () => {
|
||||
expect(read({ ...PAUSED, library: [] }).library).toEqual([]);
|
||||
});
|
||||
|
||||
it('a list that is not a list is dropped, the state is kept', () => {
|
||||
for (const library of ['qwen', 7, { 0: QWEN }, null, true]) {
|
||||
const state = read({ ...INSTALLED, library });
|
||||
expect(state.state).toBe('installed');
|
||||
expect(state).not.toHaveProperty('library');
|
||||
}
|
||||
});
|
||||
|
||||
it('drops an entry that could never be switched to: without its id, label, context or digest', () => {
|
||||
const library = [
|
||||
{ ...QWEN, id: '' },
|
||||
{ ...QWEN, label: 7 },
|
||||
{ ...QWEN, context: 0 },
|
||||
{ ...QWEN, context: '131072' },
|
||||
{ ...QWEN, digest: undefined },
|
||||
'qwen3.6:35b-a3b',
|
||||
null,
|
||||
['qwen3.6:35b-a3b'],
|
||||
MIMO,
|
||||
];
|
||||
expect(read({ ...INSTALLED, library }).library).toEqual([MIMO]);
|
||||
});
|
||||
|
||||
it('keeps one entry per model: the first', () => {
|
||||
const library = [QWEN, { ...QWEN, context: 65536 }, MIMO];
|
||||
expect(read({ ...INSTALLED, library }).library).toEqual([QWEN, MIMO]);
|
||||
});
|
||||
|
||||
it('drops what it does not know inside an entry, and a member of the wrong type: an entry holds no path', () => {
|
||||
const library = [
|
||||
{
|
||||
...QWEN,
|
||||
path: '/Users/me/.codeman/local-model/models',
|
||||
blobs: ['/etc/passwd'],
|
||||
bytes: -5,
|
||||
cli: { id: 'opencode' },
|
||||
verifiedAt: 7,
|
||||
},
|
||||
];
|
||||
const [entry] = read({ ...INSTALLED, library }).library ?? [];
|
||||
expect(entry).toEqual({ id: QWEN.id, label: QWEN.label, context: QWEN.context, digest: QWEN.digest });
|
||||
expect(JSON.stringify(read({ ...INSTALLED, library }))).not.toContain('/');
|
||||
});
|
||||
|
||||
it('reads at most 64 entries of a list, however long it is', () => {
|
||||
const library = Array.from({ length: 500 }, (_unused, i) => ({ ...MIMO, id: `model-${i}:9b` }));
|
||||
expect(read({ ...INSTALLED, library }).library).toHaveLength(64);
|
||||
});
|
||||
|
||||
it('an idle file has no list, whatever it says', () => {
|
||||
expect(read({ schema: 1, state: 'idle', library: [QWEN] })).toEqual(IDLE_LOCAL_MODEL_STATE);
|
||||
});
|
||||
});
|
||||
|
||||
describe('readLocalModelState', () => {
|
||||
it('is idle when the file is missing, and never throws', async () => {
|
||||
expect(existsSync(FILE())).toBe(false);
|
||||
|
||||
@@ -11,7 +11,11 @@
|
||||
* the fill follows the selection;
|
||||
* - the selected row carries the feature's colour on its reading edge;
|
||||
* - the welcome button is really gone while it is `hidden`, and the window paints above App
|
||||
* Settings.
|
||||
* Settings;
|
||||
* - the models on this Mac: the sizes on disk end on one edge under their caption, a row's
|
||||
* buttons end on the list's edge, the sizes do not move when a row's Delete question opens,
|
||||
* a long list scrolls from its top (it is never cut off above the frame), and under 560px a
|
||||
* row is two lines with nothing running off the side.
|
||||
*
|
||||
* The REAL index.html is served with its scripts taken out and the two shipping stylesheets
|
||||
* inlined (mobile.css under the media query its `<link>` carries); the real module runs on a
|
||||
@@ -124,10 +128,54 @@ const STATUS = {
|
||||
developerTools: 'present',
|
||||
runtime: { version: '0.40.15', installed: false, downloadBytes: RUNTIME, downloadGb: '0.2', unpackedGb: '0.6' },
|
||||
folder: '/Users/sam/.codeman/local-model',
|
||||
library: [],
|
||||
actions: ['install'],
|
||||
},
|
||||
};
|
||||
|
||||
const libraryRow = (id: string, label: string, sizeGb: string, over: object = {}) => ({
|
||||
id,
|
||||
label,
|
||||
context: 131072,
|
||||
sizeBytes: Number(sizeGb) * 1e9,
|
||||
sizeGb,
|
||||
active: false,
|
||||
verified: true,
|
||||
status: 'ready',
|
||||
actions: ['use', 'delete'],
|
||||
...over,
|
||||
});
|
||||
|
||||
/** An installed model with two more models beside it: one checked, one the runtime lists and Codeman never checked. */
|
||||
const INSTALLED = {
|
||||
...STATUS,
|
||||
install: {
|
||||
...STATUS.install,
|
||||
state: 'installed',
|
||||
running: true,
|
||||
runtime: { version: '0.40.15', installed: true, downloadBytes: 0, downloadGb: '0.0', unpackedGb: '0.6' },
|
||||
model: { id: 'qwen3.6:35b-a3b', label: 'Qwen3.6 35B-A3B' },
|
||||
cli: 'opencode',
|
||||
cliLabel: 'OpenCode',
|
||||
context: 131072,
|
||||
port: 11435,
|
||||
endpointId: 'local-ollama',
|
||||
loaded: { context: 131072, memoryBytes: 26300000000, memoryGb: '26.3', onGpu: true },
|
||||
library: [
|
||||
libraryRow('qwen3.6:35b-a3b', 'Qwen3.6 35B-A3B', '23.1', { active: true, actions: [] }),
|
||||
libraryRow('qwen3.8:27b-mtp-q4_k_m', 'Qwen3.8 27B', '17.7'),
|
||||
libraryRow('mimo-v2.6:9b-q4_k_m', 'MiMo V2.6 9B', '6.6', {
|
||||
context: undefined,
|
||||
verified: false,
|
||||
status: 'unverified',
|
||||
actions: ['delete'],
|
||||
}),
|
||||
],
|
||||
store: { bytes: 47400000000, gb: '47.4' },
|
||||
actions: ['start-session', 'stop', 'install', 'use', 'delete-model', 'remove'],
|
||||
},
|
||||
};
|
||||
|
||||
type Box = { left: number; right: number; top: number; bottom: number; width: number; height: number };
|
||||
|
||||
/** What the page holds once the module ran on the stand-in class. */
|
||||
@@ -160,7 +208,7 @@ describe('the local model window (real Chromium)', () => {
|
||||
});
|
||||
|
||||
/** The real page at a width, the module loaded, the answer in, the window closed. */
|
||||
async function load(width: number, height = 900) {
|
||||
async function load(width: number, height = 900, answer: object = STATUS) {
|
||||
p = await browser.newPage({ viewport: { width, height }, deviceScaleFactor: 1 });
|
||||
// No motion: the bar is at its width as soon as it is painted, so nothing here waits on a clock.
|
||||
await p.emulateMedia({ reducedMotion: 'reduce' });
|
||||
@@ -178,7 +226,7 @@ describe('the local model window (real Chromium)', () => {
|
||||
w.app = new w.CodemanApp();
|
||||
w.app._api = async () => ({ ok: true, status: 200, json: async () => ({ success: true, data: status }) });
|
||||
await w.app.loadLocalModel();
|
||||
}, STATUS);
|
||||
}, answer);
|
||||
}
|
||||
|
||||
/** Two animation frames: whatever was just written is laid out and painted. */
|
||||
@@ -343,6 +391,125 @@ describe('the local model window (real Chromium)', () => {
|
||||
expect(overflow).toBeLessThanOrEqual(0);
|
||||
});
|
||||
|
||||
it('the models on this Mac: sizes on one edge, a row one line, its buttons on the list edge', async () => {
|
||||
await load(1440, 900, INSTALLED);
|
||||
await open();
|
||||
const rows = await boxes('.lm-lib-row');
|
||||
expect(rows).toHaveLength(3);
|
||||
const names = await boxes('.lm-lib-name');
|
||||
const tags = await boxes('.lm-lib-tag');
|
||||
const sizes = await boxes('.lm-lib-size');
|
||||
const [caption] = await boxes('.lm-lib-caption-size');
|
||||
expect(same(sizes.map((box) => box.right))).toBe(true);
|
||||
expect(same([caption.right, sizes[0].right])).toBe(true);
|
||||
expect(same(names.map((box) => box.left))).toBe(true);
|
||||
expect(same(tags.map((box) => box.left))).toBe(true);
|
||||
// One line a row: the name, the tag, the size and the buttons share its middle.
|
||||
const middle = (box: Box) => (box.top + box.bottom) / 2;
|
||||
const actions = await boxes('.lm-lib-row .lm-lib-actions');
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
expect(Math.abs(middle(names[i]) - middle(sizes[i]))).toBeLessThan(3);
|
||||
expect(Math.abs(middle(tags[i]) - middle(sizes[i]))).toBeLessThan(3);
|
||||
expect(names[i].right).toBeLessThanOrEqual(tags[i].left + 0.6);
|
||||
expect(sizes[i].right).toBeLessThanOrEqual(actions[i].left + 0.6);
|
||||
}
|
||||
// The last button of a row ends where every line of the window ends: on "Add a model"'s edge.
|
||||
const rowButtons = await boxes('.lm-lib-row .lm-lib-actions button:last-child');
|
||||
const [add] = await boxes('.lm-lib-foot button');
|
||||
expect(same([...rowButtons.map((box) => box.right), add.right])).toBe(true);
|
||||
// The model in use carries the feature's colour on its reading edge; the others do not.
|
||||
const paint = await p.evaluate(() => {
|
||||
const probe = document.body.appendChild(document.createElement('i'));
|
||||
probe.style.color = 'var(--lm-brand)';
|
||||
const brand = getComputedStyle(probe).color;
|
||||
probe.remove();
|
||||
const edge = (sel: string) => getComputedStyle(document.querySelector(sel)!).borderLeftColor;
|
||||
return { brand, on: edge('.lm-lib-row-on'), off: edge('.lm-lib-row:not(.lm-lib-row-on)') };
|
||||
});
|
||||
expect(paint.on).toBe(paint.brand);
|
||||
expect(paint.off).not.toBe(paint.brand);
|
||||
// The screen starts at the top of the frame, right under the header.
|
||||
const [body] = await boxes('#localModelWindowBody');
|
||||
const [title] = await boxes('.lm-title');
|
||||
expect(title.top - body.top).toBeLessThan(40);
|
||||
});
|
||||
|
||||
it('the Delete question opens under its row without moving the sizes', async () => {
|
||||
await load(1440, 900, INSTALLED);
|
||||
await open();
|
||||
const before = await boxes('.lm-lib-size');
|
||||
await p.click('.lm-lib-row:nth-of-type(3) .lm-lib-actions button:last-child');
|
||||
await painted();
|
||||
const [ask] = await boxes('.lm-lib-ask');
|
||||
const rows = await boxes('.lm-lib-row');
|
||||
const after = await boxes('.lm-lib-size');
|
||||
// Sideways nothing moved; the rows after the question moved down by it.
|
||||
expect(after.map((box) => Math.round(box.right))).toEqual(before.map((box) => Math.round(box.right)));
|
||||
expect(ask.top).toBeGreaterThanOrEqual(rows[1].bottom - 1);
|
||||
expect(rows[2].top).toBeGreaterThanOrEqual(ask.bottom - 1);
|
||||
// The row's own buttons are unseen while it is asked about, and the red button is the question's.
|
||||
const seen = await p.evaluate(() => ({
|
||||
away: getComputedStyle(document.querySelector('.lm-lib-actions-away')!).visibility,
|
||||
red: [...document.querySelectorAll('.local-model-window .wt-btn-danger')].map((el) => el.textContent),
|
||||
focus: document.activeElement?.textContent,
|
||||
}));
|
||||
expect(seen).toEqual({ away: 'hidden', red: ['Delete'], focus: 'Cancel' });
|
||||
});
|
||||
|
||||
it('a long list of models scrolls from its top: the first lines are never cut off above the frame', async () => {
|
||||
const many = Array.from({ length: 14 }, (_unused, i) => libraryRow(`model-${i}:9b`, `Model ${i}`, '6.6'));
|
||||
const answer = {
|
||||
...INSTALLED,
|
||||
install: { ...INSTALLED.install, library: [...INSTALLED.install.library, ...many] },
|
||||
};
|
||||
for (const [width, height] of [
|
||||
[1440, 900],
|
||||
[390, 844],
|
||||
]) {
|
||||
await load(width, height, answer);
|
||||
await open();
|
||||
const scroll = await p.evaluate(() => {
|
||||
const body = document.getElementById('localModelWindowBody')!;
|
||||
const title = body.querySelector('.lm-title')!.getBoundingClientRect();
|
||||
const box = body.getBoundingClientRect();
|
||||
return {
|
||||
overflows: body.scrollHeight > body.clientHeight,
|
||||
top: body.scrollTop,
|
||||
titleInside: title.top >= box.top - 0.5,
|
||||
sideways: body.scrollWidth - body.clientWidth,
|
||||
};
|
||||
});
|
||||
expect(scroll).toEqual({ overflows: true, top: 0, titleInside: true, sideways: 0 });
|
||||
// The buttons of the screen stay in the frame's foot, under the part that scrolls.
|
||||
const [foot] = await boxes('#localModelWindowFoot');
|
||||
const [win] = await boxes('.local-model-window');
|
||||
expect(foot.bottom).toBeLessThanOrEqual(win.bottom + 0.6);
|
||||
await p.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('the models on this Mac under 560px: two lines a row, nothing off the side', async () => {
|
||||
await load(390, 844, INSTALLED);
|
||||
await open();
|
||||
const names = await boxes('.lm-lib-name');
|
||||
const tags = await boxes('.lm-lib-tag');
|
||||
const sizes = await boxes('.lm-lib-size');
|
||||
for (let i = 0; i < names.length; i++) {
|
||||
// The name and its size, then the state and what can be done.
|
||||
expect(tags[i].top).toBeGreaterThanOrEqual(names[i].bottom - 1);
|
||||
expect(sizes[i].top).toBeLessThan(names[i].bottom);
|
||||
expect(same([names[i].left, tags[i].left])).toBe(true);
|
||||
}
|
||||
expect(same(sizes.map((box) => box.right))).toBe(true);
|
||||
const buttons = await boxes('.lm-lib-row .lm-lib-actions button');
|
||||
for (const button of buttons) expect(button.height).toBeGreaterThanOrEqual(36);
|
||||
const overflow = await p.evaluate(() => {
|
||||
const body = document.getElementById('localModelWindowBody')!;
|
||||
return body.scrollWidth - body.clientWidth;
|
||||
});
|
||||
expect(overflow).toBeLessThanOrEqual(0);
|
||||
});
|
||||
|
||||
it('paints above App Settings when it is opened from there', async () => {
|
||||
await load(1440);
|
||||
await p.evaluate(() => document.getElementById('appSettingsModal')!.classList.add('active'));
|
||||
|
||||
+561
-2
@@ -18,6 +18,10 @@
|
||||
* screen, and a 409 `size-changed` shows the new total and sends that one next;
|
||||
* - the poll: once a second while the window is open, never after it closes, and a tick that
|
||||
* moved only the download leaves the buttons in place;
|
||||
* - the models on this Mac: the list with its sizes, the state of each row, "Use" (a switch
|
||||
* with no confirm screen), "Delete" (asked once, in the row), "Add a model" (the chooser,
|
||||
* where a row that is on this Mac already is switched to), the way back from a stopped job,
|
||||
* and what an earlier Remove left on disk;
|
||||
* - every payload string lands as text: an `<img onerror>` in a model, agent or chip name, an
|
||||
* error's detail, a session name, a folder or a command creates no element;
|
||||
* - a button exists only for an action the server lists.
|
||||
@@ -48,6 +52,7 @@ import type {
|
||||
LocalModelConflict,
|
||||
LocalModelErrorCode,
|
||||
LocalModelInstallView,
|
||||
LocalModelLibraryRow,
|
||||
LocalModelMessage,
|
||||
LocalModelStatus,
|
||||
NoneReason,
|
||||
@@ -383,11 +388,26 @@ function install(over: Partial<LocalModelInstallView> = {}): LocalModelInstallVi
|
||||
developerTools: 'present',
|
||||
runtime: RUNTIME_MISSING,
|
||||
folder: FOLDER,
|
||||
library: [],
|
||||
actions: ['install'],
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
/** The installed model as the row of the library it is: in use, checked, with nothing to do on it. */
|
||||
const QWEN_ROW: LocalModelLibraryRow = {
|
||||
id: 'qwen3.6:35b-a3b',
|
||||
label: 'Qwen3.6 35B-A3B',
|
||||
context: 131072,
|
||||
sizeBytes: 23105798956,
|
||||
sizeGb: '23.1',
|
||||
active: true,
|
||||
verified: true,
|
||||
status: 'ready',
|
||||
actions: [],
|
||||
};
|
||||
const STORE_ONE = { bytes: 23105798956, gb: '23.1' };
|
||||
|
||||
/** The members a job carries from `installing` on. */
|
||||
const JOB: Partial<LocalModelInstallView> = {
|
||||
model: { id: 'qwen3.6:35b-a3b', label: 'Qwen3.6 35B-A3B' },
|
||||
@@ -453,7 +473,9 @@ const INSTALLED = status(
|
||||
running: true,
|
||||
endpointId: 'local-ollama',
|
||||
loaded: { context: 131072, memoryBytes: 26300000000, memoryGb: '26.3', onGpu: true },
|
||||
actions: ['start-session', 'stop', 'remove'],
|
||||
library: [QWEN_ROW],
|
||||
store: STORE_ONE,
|
||||
actions: ['start-session', 'stop', 'install', 'remove'],
|
||||
}
|
||||
);
|
||||
const STOPPED = status(
|
||||
@@ -464,7 +486,9 @@ const STOPPED = status(
|
||||
running: false,
|
||||
endpointId: 'local-ollama',
|
||||
loaded: { context: 131072, memoryBytes: 26300000000, memoryGb: '26.3', onGpu: true },
|
||||
actions: ['start', 'remove'],
|
||||
library: [QWEN_ROW],
|
||||
store: STORE_ONE,
|
||||
actions: ['start', 'install', 'remove'],
|
||||
}
|
||||
);
|
||||
|
||||
@@ -1705,6 +1729,8 @@ describe('an installed model', () => {
|
||||
// row's own estimate, so the two figures are never read as a contradiction.
|
||||
expect(texts(body, '.lm-aside')).toEqual([
|
||||
'Runs with OpenCode. Context 131,072 tokens. Takes 26.3 GB of memory now, and up to about 36 GB at its peak, with all of the context in use. All of it is on the GPU.',
|
||||
// Under the list of models: what the downloads take on disk.
|
||||
'Downloaded models use 23.1 GB on this Mac.',
|
||||
]);
|
||||
expect(buttons(body)).toEqual(['Remove', 'Stop', 'Start a session']);
|
||||
// One main action.
|
||||
@@ -2369,3 +2395,536 @@ describe('an installed model whose runtime did not start', () => {
|
||||
expect(verbs(app)).toEqual([['POST', '/api/local-model/runtime', { action: 'start' }]]);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------- The models on this Mac ----------
|
||||
|
||||
const GPT_ROW: LocalModelLibraryRow = {
|
||||
id: 'gpt-oss:20b',
|
||||
label: 'GPT-OSS 20B',
|
||||
context: 65536,
|
||||
sizeBytes: 13793441427,
|
||||
sizeGb: '13.8',
|
||||
active: false,
|
||||
verified: true,
|
||||
status: 'ready',
|
||||
actions: ['use', 'delete'],
|
||||
};
|
||||
const STORE_TWO = { bytes: 36899240383, gb: '36.9' };
|
||||
|
||||
/** Two models on this Mac, the Qwen one in use and running. */
|
||||
const TWO = status(
|
||||
{ budgetBytes: 40157944217, budgetGb: '40.2', budgetSource: 'measured' },
|
||||
{
|
||||
...INSTALLED.install,
|
||||
library: [QWEN_ROW, GPT_ROW],
|
||||
store: STORE_TWO,
|
||||
actions: ['start-session', 'stop', 'install', 'use', 'delete-model', 'remove'],
|
||||
}
|
||||
);
|
||||
|
||||
/** The buttons of the rows of the list (not the buttons of the screen), by their label. */
|
||||
const rowButtons = (page: Mounted) => texts(page.body, '.lm-lib-row .lm-lib-actions:not(.lm-lib-actions-away) button');
|
||||
|
||||
function pressIn(page: Mounted, selector: string, label: string) {
|
||||
const found = [...page.frame.querySelectorAll<HTMLButtonElement>(`${selector} button`)].find(
|
||||
(el) => el.textContent === label
|
||||
);
|
||||
expect(found, `no button "${label}" in ${selector}`).toBeTruthy();
|
||||
found!.click();
|
||||
}
|
||||
|
||||
describe('the models on this Mac', () => {
|
||||
it('lists the one installed model with its context, its state and its size on disk, and offers another', async () => {
|
||||
const page = mount();
|
||||
await opened(page, INSTALLED);
|
||||
const { body } = page;
|
||||
expect(texts(body, '.lm-lib-caption-label')).toEqual(['Models on this Mac']);
|
||||
expect(texts(body, '.lm-lib-caption-size')).toEqual(['On disk']);
|
||||
expect(texts(body, '.lm-lib-label')).toEqual(['Qwen3.6 35B-A3B']);
|
||||
expect(texts(body, '.lm-lib-meta')).toEqual(['131,072 tokens']);
|
||||
expect(texts(body, '.lm-lib-tag .lm-tag')).toEqual(['In use']);
|
||||
expect(texts(body, '.lm-lib-size')).toEqual(['23.1 GB']);
|
||||
// The model in use wears the selected look, and nothing can be done ON it here.
|
||||
expect(body.querySelector('.lm-lib-row')!.classList.contains('lm-lib-row-on')).toBe(true);
|
||||
expect(rowButtons(page)).toEqual([]);
|
||||
expect(texts(body, '.lm-lib-foot button')).toEqual(['Add a model']);
|
||||
// A real list for assistive technology, named by its caption.
|
||||
const list = body.querySelector('.lm-lib-list')!;
|
||||
expect(list.getAttribute('role')).toBe('list');
|
||||
expect(list.getAttribute('aria-labelledby')).toBe('localModelLibraryLabel');
|
||||
expect(body.querySelectorAll('[role="listitem"]')).toHaveLength(1);
|
||||
// The screen scrolls from its top: it is no short screen any more.
|
||||
expect(body.dataset.long).toBe('true');
|
||||
// The buttons of the screen are what they were.
|
||||
expect(buttons(body)).toEqual(['Remove', 'Stop', 'Start a session']);
|
||||
});
|
||||
|
||||
it('two models: the one in use first, the other with Use and Delete, and what both take on disk', async () => {
|
||||
const page = mount();
|
||||
await opened(page, TWO);
|
||||
const { body } = page;
|
||||
expect(texts(body, '.lm-lib-label')).toEqual(['Qwen3.6 35B-A3B', 'GPT-OSS 20B']);
|
||||
expect(texts(body, '.lm-lib-meta')).toEqual(['131,072 tokens', '65,536 tokens']);
|
||||
expect(texts(body, '.lm-lib-tag .lm-tag')).toEqual(['In use', 'Ready']);
|
||||
expect(texts(body, '.lm-lib-size')).toEqual(['23.1 GB', '13.8 GB']);
|
||||
expect(rowButtons(page)).toEqual(['Use', 'Delete']);
|
||||
// A short label on screen, the model's name for a screen reader.
|
||||
const [use, remove] = [...body.querySelectorAll('.lm-lib-row .lm-lib-actions button')];
|
||||
expect(use.getAttribute('aria-label')).toBe('Use GPT-OSS 20B');
|
||||
expect(remove.getAttribute('aria-label')).toBe('Delete GPT-OSS 20B');
|
||||
// Quiet buttons: the screen keeps its one main action, and nothing here is red yet.
|
||||
expect(body.querySelectorAll('.lm-library .wt-btn-primary, .lm-library .wt-btn-danger')).toHaveLength(0);
|
||||
expect(texts(body, '.lm-lib-notes .lm-aside')).toEqual(['Downloaded models use 36.9 GB on this Mac.']);
|
||||
});
|
||||
|
||||
it('a row has a button only for an action the server lists for THAT row', async () => {
|
||||
const page = mount();
|
||||
const rows: LocalModelLibraryRow[] = [
|
||||
QWEN_ROW,
|
||||
{ ...GPT_ROW, actions: ['use'] },
|
||||
{ ...GPT_ROW, id: 'a:1b', label: 'A', status: 'changed', actions: ['delete'] },
|
||||
{ ...GPT_ROW, id: 'b:1b', label: 'B', status: 'missing', actions: ['delete'] },
|
||||
{ ...GPT_ROW, id: 'c:1b', label: 'C', context: undefined, verified: false, status: 'unverified', actions: [] },
|
||||
{ id: 'd:1b', label: 'D', active: false, verified: false, status: 'unverified', actions: ['delete'] },
|
||||
];
|
||||
await opened(page, status({}, { ...TWO.install, library: rows }));
|
||||
const perRow = [...page.body.querySelectorAll('.lm-lib-row')].map((row) => [
|
||||
row.querySelector('.lm-lib-label')!.textContent,
|
||||
row.querySelector('.lm-tag')!.textContent,
|
||||
texts(row, '.lm-lib-actions button').join('+'),
|
||||
]);
|
||||
expect(perRow).toEqual([
|
||||
['Qwen3.6 35B-A3B', 'In use', ''],
|
||||
['GPT-OSS 20B', 'Ready', 'Use'],
|
||||
['A', 'Changed', 'Delete'],
|
||||
['B', 'Missing', 'Delete'],
|
||||
['C', 'Not checked', ''],
|
||||
['D', 'Not checked', 'Delete'],
|
||||
]);
|
||||
// A row without a context or a size prints neither: no "undefined", no empty unit.
|
||||
const last = [...page.body.querySelectorAll('.lm-lib-row')].at(-1)!;
|
||||
expect(last.querySelector('.lm-lib-meta')).toBeNull();
|
||||
expect(last.querySelector('.lm-lib-size')!.textContent).toBe('');
|
||||
expect(page.body.textContent).not.toContain('undefined');
|
||||
// Each state has a colour of its own.
|
||||
expect([...page.body.querySelectorAll('.lm-lib-tag .lm-tag')].map((tag) => tag.className)).toEqual([
|
||||
'lm-tag lm-tag-inuse',
|
||||
'lm-tag lm-tag-ready',
|
||||
'lm-tag lm-tag-changed',
|
||||
'lm-tag lm-tag-missing',
|
||||
'lm-tag lm-tag-unverified',
|
||||
'lm-tag lm-tag-unverified',
|
||||
]);
|
||||
});
|
||||
|
||||
it('Use switches at once: no confirm screen, and the screen says a switch is running', async () => {
|
||||
const page = mount();
|
||||
const switching: LocalModelInstallView = {
|
||||
...TWO.install,
|
||||
state: 'installing',
|
||||
step: 'verify',
|
||||
steps: ['verify', 'connect'],
|
||||
model: { id: 'gpt-oss:20b', label: 'GPT-OSS 20B' },
|
||||
context: 65536,
|
||||
library: [
|
||||
{ ...QWEN_ROW, active: false },
|
||||
{ ...GPT_ROW, actions: [] },
|
||||
],
|
||||
actions: ['cancel'],
|
||||
};
|
||||
const { app } = await opened(page, TWO, () => ok(switching));
|
||||
pressIn(page, '.lm-lib-row', 'Use');
|
||||
await settle();
|
||||
expect(verbs(app)).toEqual([['POST', '/api/local-model/use', { modelId: 'gpt-oss:20b' }]]);
|
||||
expect(page.body.dataset.screen).toBe('progress');
|
||||
expect(texts(page.body, '.lm-title')).toEqual(['Switching to GPT-OSS 20B']);
|
||||
// Nothing is downloaded: no download bar, and the two steps of a switch.
|
||||
expect(page.body.querySelector('.lm-meter')).toBeNull();
|
||||
expect(texts(page.body, '.lm-step-label')).toEqual(['Loading and checking', 'Connecting']);
|
||||
expect(texts(page.body, '.lm-aside')).toEqual([
|
||||
'You can close this window. The switch continues while Codeman is running.',
|
||||
]);
|
||||
expect(buttons(page.body)).toEqual(['Cancel']);
|
||||
});
|
||||
|
||||
it('a switch that is refused keeps the screen and prints the server sentence with the session names', async () => {
|
||||
const page = mount();
|
||||
const { app } = await opened(page, TWO, () =>
|
||||
conflict({
|
||||
reason: 'sessions-open',
|
||||
message: { text: LOCAL_MODEL_INSTALL_LABELS.conflictSessionsOpen, vars: { sessions: 'w1-app, w2-api' } },
|
||||
sessions: ['w1-app', 'w2-api'],
|
||||
})
|
||||
);
|
||||
pressIn(page, '.lm-lib-row', 'Use');
|
||||
await settle();
|
||||
expect(page.body.dataset.screen).toBe('installed');
|
||||
expect(texts(page.body, '.lm-notice-text')).toEqual([
|
||||
'Sessions are still using this model: w1-app, w2-api. Close them first.',
|
||||
]);
|
||||
expect(verbs(app)).toHaveLength(1);
|
||||
expect(rowButtons(page)).toEqual(['Use', 'Delete']);
|
||||
});
|
||||
|
||||
it('Delete asks once, in the row, and sends nothing until the red button is pressed', async () => {
|
||||
const page = mount();
|
||||
const after: LocalModelInstallView = {
|
||||
...TWO.install,
|
||||
library: [QWEN_ROW],
|
||||
store: STORE_ONE,
|
||||
actions: ['start-session', 'stop', 'install', 'remove'],
|
||||
};
|
||||
const { app } = await opened(page, TWO, () => ok(after));
|
||||
pressIn(page, '.lm-lib-row', 'Delete');
|
||||
await settle();
|
||||
expect(verbs(app)).toEqual([]);
|
||||
const ask = page.body.querySelector('.lm-lib-ask')!;
|
||||
expect(texts(ask, '.lm-lead')).toEqual(['Delete GPT-OSS 20B from this Mac?']);
|
||||
expect(texts(ask, '.lm-aside')).toEqual(['This frees 13.8 GB.']);
|
||||
expect(texts(ask, 'button')).toEqual(['Cancel', 'Delete']);
|
||||
// Red only here, in its own question.
|
||||
expect(page.frame.querySelectorAll('.wt-btn-danger')).toHaveLength(1);
|
||||
expect(ask.querySelector('.wt-btn-danger')!.textContent).toBe('Delete');
|
||||
// The question sits right under the row it is about. That row's own buttons keep their
|
||||
// room (the sizes must not jump) and are out of reach: unseen, disabled, hidden from a reader.
|
||||
const asked = ask.previousElementSibling!;
|
||||
expect(asked.querySelector('.lm-lib-label')!.textContent).toBe('GPT-OSS 20B');
|
||||
expect(rowButtons(page)).toEqual([]);
|
||||
const away = asked.querySelector('.lm-lib-actions')!;
|
||||
expect(away.classList.contains('lm-lib-actions-away')).toBe(true);
|
||||
expect(away.getAttribute('aria-hidden')).toBe('true');
|
||||
expect([...away.querySelectorAll('button')].every((button) => button.disabled)).toBe(true);
|
||||
// The focus is on the safe answer.
|
||||
expect((page.document.activeElement as HTMLElement).textContent).toBe('Cancel');
|
||||
|
||||
pressIn(page, '.lm-lib-ask', 'Cancel');
|
||||
await settle();
|
||||
expect(page.body.querySelector('.lm-lib-ask')).toBeNull();
|
||||
expect(rowButtons(page)).toEqual(['Use', 'Delete']);
|
||||
expect(verbs(app)).toEqual([]);
|
||||
// Back on the button that asked.
|
||||
expect((page.document.activeElement as HTMLElement).dataset.lmKey).toBe('delete:gpt-oss:20b');
|
||||
|
||||
pressIn(page, '.lm-lib-row', 'Delete');
|
||||
await settle();
|
||||
pressIn(page, '.lm-lib-ask', 'Delete');
|
||||
await settle();
|
||||
expect(verbs(app)).toEqual([['DELETE', '/api/local-model/models', { modelId: 'gpt-oss:20b' }]]);
|
||||
expect(texts(page.body, '.lm-lib-label')).toEqual(['Qwen3.6 35B-A3B']);
|
||||
expect(page.body.querySelector('.lm-lib-ask')).toBeNull();
|
||||
expect(texts(page.body, '.lm-lib-notes .lm-aside')).toEqual(['Downloaded models use 23.1 GB on this Mac.']);
|
||||
});
|
||||
|
||||
it('a delete the server refuses leaves the row and prints why', async () => {
|
||||
const page = mount();
|
||||
await opened(page, TWO, () =>
|
||||
conflict({
|
||||
reason: 'model-active',
|
||||
message: { text: LOCAL_MODEL_INSTALL_LABELS.conflictModelActive, vars: { model: 'GPT-OSS 20B' } },
|
||||
})
|
||||
);
|
||||
pressIn(page, '.lm-lib-row', 'Delete');
|
||||
await settle();
|
||||
pressIn(page, '.lm-lib-ask', 'Delete');
|
||||
await settle();
|
||||
expect(texts(page.body, '.lm-notice-text')).toEqual([
|
||||
'GPT-OSS 20B is the model in use. Switch to another model first, or remove the local model.',
|
||||
]);
|
||||
expect(texts(page.body, '.lm-lib-label')).toEqual(['Qwen3.6 35B-A3B', 'GPT-OSS 20B']);
|
||||
});
|
||||
|
||||
it('the Delete question of a row and the Remove question are never open together', async () => {
|
||||
const page = mount();
|
||||
await opened(page, TWO);
|
||||
pressIn(page, '.lm-lib-row', 'Delete');
|
||||
await settle();
|
||||
expect(page.body.querySelector('.lm-lib-ask')).not.toBeNull();
|
||||
press(page.body, 'Remove');
|
||||
await settle();
|
||||
expect(page.frame.querySelector('.lm-remove')).not.toBeNull();
|
||||
expect(page.frame.querySelector('.lm-lib-ask')).toBeNull();
|
||||
});
|
||||
|
||||
it('with the runtime stopped a row offers no delete, and one line says how to get it', async () => {
|
||||
const page = mount();
|
||||
const stopped = status(
|
||||
{},
|
||||
{
|
||||
...STOPPED.install,
|
||||
library: [QWEN_ROW, { ...GPT_ROW, actions: ['use'] }],
|
||||
store: STORE_TWO,
|
||||
actions: ['start', 'install', 'use', 'remove'],
|
||||
}
|
||||
);
|
||||
await opened(page, stopped);
|
||||
expect(rowButtons(page)).toEqual(['Use']);
|
||||
expect(texts(page.body, '.lm-lib-notes .lm-aside')).toEqual([
|
||||
'Downloaded models use 36.9 GB on this Mac.',
|
||||
'Press Start to delete a single model.',
|
||||
]);
|
||||
// With one model there is no single model to delete, so the line is not there.
|
||||
const one = mount();
|
||||
await opened(one, STOPPED);
|
||||
expect(one.body.textContent).not.toContain('Press Start to delete');
|
||||
});
|
||||
|
||||
it('the screen that ends an install says one thing: no list, one button', async () => {
|
||||
const page = mount();
|
||||
const { app, serve } = await opened(page, INSTALLING);
|
||||
serve(TWO);
|
||||
await app.loadLocalModel({ force: true });
|
||||
expect(page.body.dataset.screen).toBe('installed');
|
||||
expect(page.body.querySelector('.lm-library')).toBeNull();
|
||||
expect(page.body.dataset.long).toBe('false');
|
||||
expect(buttons(page.body)).toEqual(['Start a session']);
|
||||
});
|
||||
|
||||
it('"Add a model" opens the chooser on an installed model, and Back returns to it', async () => {
|
||||
const page = mount();
|
||||
const { app } = await opened(page, TWO);
|
||||
pressIn(page, '.lm-lib-foot', 'Add a model');
|
||||
await settle();
|
||||
expect(page.body.dataset.screen).toBe('choose');
|
||||
expect(page.body.querySelectorAll('[role="radio"]').length).toBeGreaterThan(1);
|
||||
expect(buttons(page.body)).toEqual(['Back', 'Install on this Mac']);
|
||||
press(page.body, 'Back');
|
||||
await settle();
|
||||
expect(page.body.dataset.screen).toBe('installed');
|
||||
expect(verbs(app)).toEqual([]);
|
||||
// No button without the server offering an install (no row fits, or it is not listed).
|
||||
const none = mount();
|
||||
await opened(none, status({}, { ...TWO.install, actions: ['start-session', 'stop', 'remove'] }));
|
||||
expect(texts(none.body, '.lm-lib-foot button')).toEqual([]);
|
||||
});
|
||||
|
||||
it('in the chooser a model that is on this Mac says so, and is switched to without a download', async () => {
|
||||
const page = mount();
|
||||
const answer = status(
|
||||
{
|
||||
pick: { ...PICK, library: 'active' },
|
||||
alternatives: [{ ...ALT_SLOW, library: 'ready' }, ALT_SMALL],
|
||||
},
|
||||
TWO.install
|
||||
);
|
||||
const { app } = await opened(page, answer, () => ok({ ...TWO.install, state: 'installing', step: 'verify' }));
|
||||
pressIn(page, '.lm-lib-foot', 'Add a model');
|
||||
await settle();
|
||||
const rows = [...page.body.querySelectorAll('.lm-model')];
|
||||
const tagsOf = (row: Element) => texts(row, '.lm-tag');
|
||||
// The model in use: its own tag beside the server's, and nothing to do with it here.
|
||||
expect(tagsOf(rows[0])).toContain('In use');
|
||||
const primary = () => page.frame.querySelector<HTMLButtonElement>('.lm-buttons .wt-btn-primary')!;
|
||||
expect(primary().textContent).toBe('In use');
|
||||
expect(primary().disabled).toBe(true);
|
||||
|
||||
// A model that is on this Mac and checked: one click switches to it.
|
||||
const ready = rows.find((row) => tagsOf(row).includes('On this Mac'))!;
|
||||
expect(ready).toBeTruthy();
|
||||
(ready as HTMLElement).click();
|
||||
await settle();
|
||||
expect(primary().textContent).toBe('Use this model');
|
||||
expect(primary().disabled).toBe(false);
|
||||
primary().click();
|
||||
await settle();
|
||||
expect(verbs(app)).toEqual([['POST', '/api/local-model/use', { modelId: ALT_SLOW.modelId }]]);
|
||||
// No confirm screen was shown on the way.
|
||||
expect(page.body.dataset.screen).toBe('progress');
|
||||
});
|
||||
|
||||
it('in the chooser a model that is not on this Mac is installed the usual way, with its sizes confirmed', async () => {
|
||||
const page = mount();
|
||||
const answer = status({ pick: { ...PICK, library: 'active' }, alternatives: [ALT_SMALL] }, TWO.install);
|
||||
const { app } = await opened(page, answer, () => ok({ ...TWO.install, state: 'installing', step: 'model' }));
|
||||
pressIn(page, '.lm-lib-foot', 'Add a model');
|
||||
await settle();
|
||||
const other = [...page.body.querySelectorAll<HTMLElement>('.lm-model')].find(
|
||||
(row) => row.dataset.modelId === ALT_SMALL.modelId
|
||||
)!;
|
||||
other.click();
|
||||
await settle();
|
||||
press(page.body, 'Install on this Mac');
|
||||
await settle();
|
||||
expect(page.body.dataset.screen).toBe('confirm');
|
||||
press(page.body, 'Download and install');
|
||||
await settle();
|
||||
expect(verbs(app)).toEqual([
|
||||
[
|
||||
'POST',
|
||||
'/api/local-model/install',
|
||||
{
|
||||
modelId: ALT_SMALL.modelId,
|
||||
cli: ALT_SMALL.harnesses[0].cli,
|
||||
context: ALT_SMALL.context,
|
||||
acknowledgedBytes: ALT_SMALL.installBytes,
|
||||
},
|
||||
],
|
||||
]);
|
||||
});
|
||||
|
||||
it('a job that stopped beside a checked model shows the way back to that model', async () => {
|
||||
const back: LocalModelLibraryRow = { ...QWEN_ROW, active: false, actions: ['use', 'delete'] };
|
||||
for (const first of [
|
||||
status(
|
||||
{},
|
||||
{ ...PAUSED.install, library: [back], actions: ['resume', 'install', 'use', 'delete-model', 'remove'] }
|
||||
),
|
||||
status(
|
||||
{},
|
||||
{
|
||||
...failed('no-tool-call', { model: 'GPT-OSS 20B' }).install,
|
||||
library: [back],
|
||||
actions: ['resume', 'install', 'use', 'delete-model', 'remove'],
|
||||
}
|
||||
),
|
||||
]) {
|
||||
const page = mount();
|
||||
const { app } = await opened(page, first, () => ok({ ...TWO.install, state: 'installing', step: 'verify' }));
|
||||
expect(texts(page.body, '.lm-lib-label')).toEqual(['Qwen3.6 35B-A3B']);
|
||||
expect(texts(page.body, '.lm-lib-tag .lm-tag')).toEqual(['Ready']);
|
||||
expect(rowButtons(page)).toEqual(['Use', 'Delete']);
|
||||
// No "Add a model" here: the job's own buttons offer another model.
|
||||
expect(texts(page.body, '.lm-lib-foot button')).toEqual([]);
|
||||
pressIn(page, '.lm-lib-row', 'Use');
|
||||
await settle();
|
||||
expect(verbs(app)).toEqual([['POST', '/api/local-model/use', { modelId: 'qwen3.6:35b-a3b' }]]);
|
||||
}
|
||||
});
|
||||
|
||||
it('a first job that stopped has nothing to go back to, and shows no list', async () => {
|
||||
const page = mount();
|
||||
// Its own model is on disk and checked, with nothing offered on it: Resume goes on with it.
|
||||
await opened(page, status({}, { ...PAUSED.install, library: [{ ...QWEN_ROW, active: false }] }));
|
||||
expect(page.body.querySelector('.lm-library')).toBeNull();
|
||||
expect(page.body.dataset.long).toBe('false');
|
||||
const stopped = mount();
|
||||
await opened(stopped, failed('load-failed', { model: 'Qwen3.6 35B-A3B' }));
|
||||
expect(stopped.body.querySelector('.lm-library')).toBeNull();
|
||||
});
|
||||
|
||||
it('a paused switch says so, and waits', async () => {
|
||||
const page = mount();
|
||||
await opened(
|
||||
page,
|
||||
status({}, { ...PAUSED.install, step: 'verify', steps: ['verify', 'connect'], progress: undefined })
|
||||
);
|
||||
expect(texts(page.body, '.lm-title')).toEqual(['The switch to Qwen3.6 35B-A3B is paused']);
|
||||
expect(texts(page.body, '.lm-aside')).toEqual(['The switch waits until you continue.']);
|
||||
expect(page.body.querySelector('.lm-meter')).toBeNull();
|
||||
});
|
||||
|
||||
it('the App Settings row names a switch as a switch', async () => {
|
||||
const page = mount();
|
||||
const job = { ...JOB, steps: ['verify', 'connect'] as LocalModelInstallView['steps'], step: 'verify' as const };
|
||||
for (const [state, text] of [
|
||||
['installing', 'Switching to Qwen3.6 35B-A3B'],
|
||||
['paused', 'The switch to Qwen3.6 35B-A3B is paused'],
|
||||
['failed', 'The switch to Qwen3.6 35B-A3B stopped'],
|
||||
] as const) {
|
||||
const app = page.newApp(ok(status({}, { ...job, state, actions: [] })));
|
||||
await app.loadLocalModel({ force: true });
|
||||
expect(texts(page.row, '.set-row-label')).toEqual([text]);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('what an earlier Remove left on disk', () => {
|
||||
const LEFT = status({}, { store: STORE_ONE, actions: ['install', 'remove'] });
|
||||
|
||||
it('the chooser says what the downloads still take, and deletes them after one question', async () => {
|
||||
const page = mount();
|
||||
const { app } = await opened(page, LEFT, () => ok(install({ store: { bytes: 0, gb: '0.0' } })));
|
||||
expect(texts(page.body, '.lm-leftovers .lm-aside')).toEqual([
|
||||
'Models from an earlier install still use 23.1 GB on this Mac.',
|
||||
]);
|
||||
pressIn(page, '.lm-leftovers', 'Delete them');
|
||||
await settle();
|
||||
expect(verbs(app)).toEqual([]);
|
||||
expect(texts(page.body, '.lm-leftovers .lm-lead')).toEqual(['Delete the downloaded models from this Mac?']);
|
||||
expect(texts(page.body, '.lm-leftovers .lm-lib-ask .lm-aside')).toEqual(['This frees 23.1 GB.']);
|
||||
expect(page.body.querySelector('.lm-leftovers .wt-btn-danger')!.textContent).toBe('Delete');
|
||||
pressIn(page, '.lm-leftovers', 'Cancel');
|
||||
await settle();
|
||||
expect(texts(page.body, '.lm-leftovers button')).toEqual(['Delete them']);
|
||||
pressIn(page, '.lm-leftovers', 'Delete them');
|
||||
await settle();
|
||||
pressIn(page, '.lm-leftovers', 'Delete');
|
||||
await settle();
|
||||
expect(verbs(app)).toEqual([['DELETE', '/api/local-model', { removeModel: true }]]);
|
||||
expect(page.body.querySelector('.lm-leftovers')).toBeNull();
|
||||
// Still the chooser: installing is what this screen is for.
|
||||
expect(page.body.dataset.screen).toBe('choose');
|
||||
expect(buttons(page.body)).toEqual(['Install on this Mac']);
|
||||
});
|
||||
|
||||
it('is not there without something on disk, or without the server offering the removal', async () => {
|
||||
for (const first of [
|
||||
IDLE,
|
||||
status({}, { store: { bytes: 0, gb: '0.0' }, actions: ['install', 'remove'] }),
|
||||
status({}, { store: STORE_ONE, actions: ['install'] }),
|
||||
]) {
|
||||
const page = mount();
|
||||
await opened(page, first);
|
||||
expect(page.body.querySelector('.lm-leftovers')).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it('shows on a Mac no model fits too: the space can still be freed', async () => {
|
||||
const page = mount();
|
||||
const answer = none('memory');
|
||||
await opened(page, { ...answer, install: install({ store: STORE_ONE, actions: ['remove'] }) });
|
||||
expect(page.body.dataset.screen).toBe('none');
|
||||
expect(texts(page.body, '.lm-leftovers .lm-aside')).toEqual([
|
||||
'Models from an earlier install still use 23.1 GB on this Mac.',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the models on this Mac: text of someone else, another language, an unchanged answer', () => {
|
||||
it('a model name is text, in the row, in its question and in the labels of its buttons', async () => {
|
||||
const HOSTILE = '<img src=x onerror="window.__pwned=1">';
|
||||
const page = mount();
|
||||
const rows: LocalModelLibraryRow[] = [QWEN_ROW, { ...GPT_ROW, id: `evil:1b`, label: `Model ${HOSTILE}` }];
|
||||
await opened(page, status({}, { ...TWO.install, library: rows, folder: `/tmp/${HOSTILE}` }));
|
||||
pressIn(page, '.lm-lib-row', 'Delete');
|
||||
await settle();
|
||||
expect(page.document.querySelectorAll('img, script, iframe')).toHaveLength(0);
|
||||
expect((page.win as unknown as { __pwned?: number }).__pwned).toBeUndefined();
|
||||
expect(texts(page.body, '.lm-lib-ask .lm-lead')).toEqual([`Delete Model ${HOSTILE} from this Mac?`]);
|
||||
});
|
||||
|
||||
it('is translated when the language changes, the names staying as sent', async () => {
|
||||
const page = mount({ i18n: true });
|
||||
page.win.CodemanI18n.start();
|
||||
await opened(page, TWO);
|
||||
page.win.CodemanI18n.configure({ language: 'zh-CN' });
|
||||
await settle();
|
||||
expect(texts(page.body, '.lm-lib-caption-label')).toEqual(['此 Mac 上的模型']);
|
||||
expect(texts(page.body, '.lm-lib-tag .lm-tag')).toEqual(['使用中', '就绪']);
|
||||
expect(rowButtons(page)).toEqual(['使用', '删除']);
|
||||
expect(texts(page.body, '.lm-lib-label')).toEqual(['Qwen3.6 35B-A3B', 'GPT-OSS 20B']);
|
||||
expect(texts(page.body, '.lm-lib-notes .lm-aside')).toEqual(['已下载的模型在此 Mac 上占用 36.9 GB。']);
|
||||
expect(texts(page.body, '.lm-lib-foot button')).toEqual(['添加模型']);
|
||||
});
|
||||
|
||||
it('a poll that changes nothing leaves the rows and their buttons in place', async () => {
|
||||
const page = mount();
|
||||
await opened(page, TWO);
|
||||
const before = page.body.querySelector('.lm-lib-row .lm-lib-actions button');
|
||||
await page.tick();
|
||||
await page.tick();
|
||||
expect(page.body.querySelector('.lm-lib-row .lm-lib-actions button')).toBe(before);
|
||||
});
|
||||
|
||||
it('the focus stays on the row button it was on when the list is repainted', async () => {
|
||||
const page = mount();
|
||||
const { app, serve } = await opened(page, TWO);
|
||||
const remove = [...page.body.querySelectorAll<HTMLButtonElement>('.lm-lib-row .lm-lib-actions button')][1];
|
||||
remove.focus();
|
||||
serve(status({}, { ...TWO.install, store: { bytes: 36899240384, gb: '36.9' }, port: 11436 }));
|
||||
await app.loadLocalModel({ force: true });
|
||||
const active = page.document.activeElement as HTMLElement;
|
||||
expect(active.textContent).toBe('Delete');
|
||||
expect(active.dataset.lmKey).toBe('delete:gpt-oss:20b');
|
||||
expect(active).not.toBe(remove);
|
||||
});
|
||||
});
|
||||
@@ -1,59 +0,0 @@
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { chromium, type Browser } from 'playwright';
|
||||
import { WebServer } from '../src/web/server.js';
|
||||
|
||||
const id = 'log-reconnect-fixture',
|
||||
path = '/workspace/demo.log';
|
||||
describe('log viewer pre-connect failures', () => {
|
||||
let server: WebServer, browser: Browser;
|
||||
beforeAll(async () => {
|
||||
server = new WebServer(0, false, true);
|
||||
await server.start();
|
||||
browser = await chromium.launch({ headless: true });
|
||||
}, 60000);
|
||||
afterAll(async () => {
|
||||
await browser?.close();
|
||||
await server?.stop();
|
||||
});
|
||||
it.each([
|
||||
'Path must be within working directory or allowed log directories',
|
||||
'File does not exist',
|
||||
'File not found or not accessible',
|
||||
'File too large',
|
||||
'Maximum 5 concurrent streams per session',
|
||||
'Failed to spawn tail process',
|
||||
])('does not reconnect after %s before connected', async (error) => {
|
||||
const page = await browser.newPage(),
|
||||
errors: string[] = [];
|
||||
let requests = 0;
|
||||
page.on('pageerror', (e) => errors.push(e.message));
|
||||
await page.route(`**/api/sessions/${id}/tail-file?**`, async (route) => {
|
||||
requests++;
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'text/event-stream',
|
||||
// retry: 200 makes a reconnect, if one came, show up within 200 ms.
|
||||
body: `retry: 200\ndata: ${JSON.stringify({ type: 'error', error })}\n\n`,
|
||||
});
|
||||
});
|
||||
try {
|
||||
await page.goto(`http://127.0.0.1:${server.boundPort}`, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForFunction(() => (window as any).app?.openLogViewerWindow);
|
||||
await page.waitForTimeout(3500);
|
||||
await page.evaluate(
|
||||
({ id, path }) => {
|
||||
const app = (window as any).app;
|
||||
app.sessions.set(id, { id, mode: 'shell', workingDir: '/workspace' });
|
||||
app.openLogViewerWindow(path, id);
|
||||
},
|
||||
{ id, path }
|
||||
);
|
||||
await expect.poll(() => page.locator('.log-viewer-window').innerText()).toContain(error);
|
||||
await page.waitForTimeout(1000);
|
||||
expect(requests).toBe(1);
|
||||
expect(errors).toEqual([]);
|
||||
} finally {
|
||||
await page.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,309 +0,0 @@
|
||||
/**
|
||||
* @fileoverview Zoom in the path picker's image preview (salvaged from #608): pinch, one-finger pan,
|
||||
* double-click, wheel and the out / fit / in buttons. Same jsdom harness as
|
||||
* path-picker-hidden.test.ts (see its header for why the window is built by hand).
|
||||
*
|
||||
* These drive the real listeners and read the zoom state back, because what can go wrong is
|
||||
* geometric, not textual:
|
||||
*
|
||||
* 1. the point under the cursor or between the fingers drifting while the scale changes,
|
||||
* 2. a pan that carries the image out of view (a small image is drawn at its own size with
|
||||
* `object-fit: scale-down`, so the image box is not the image),
|
||||
* 3. a trackpad's stream of small wheel deltas jumping to the maximum, as 20% per event did,
|
||||
* 4. controls left on screen for an image that failed to load,
|
||||
* 5. listeners or zoom state outliving the preview, or a late `load`/`error` from a replaced
|
||||
* preview acting on the current one.
|
||||
*
|
||||
* jsdom lays nothing out and loads no images, so the viewport's rect and the image's natural size
|
||||
* are stubbed per test and `load`/`error` are dispatched by hand.
|
||||
*/
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { JSDOM } from 'jsdom';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const PUBLIC = resolve(import.meta.dirname, '../src/web/public');
|
||||
const accessoryJs = readFileSync(resolve(PUBLIC, 'keyboard-accessory.js'), 'utf8');
|
||||
const stylesCss = readFileSync(resolve(PUBLIC, 'styles.css'), 'utf8');
|
||||
|
||||
const dom = new JSDOM('<!DOCTYPE html><html><body></body></html>', { url: 'https://localhost/' });
|
||||
const jsdomWindow = dom.window as unknown as Window & typeof globalThis;
|
||||
const doc = jsdomWindow.document;
|
||||
|
||||
/** The preview body's box: 400x300 at the page origin, so its centre is (200, 150). */
|
||||
const BOX = { left: 0, top: 0, width: 400, height: 300 };
|
||||
const CENTRE = { x: BOX.left + BOX.width / 2, y: BOX.top + BOX.height / 2 };
|
||||
|
||||
type Point = { x: number; y: number };
|
||||
type ZoomState = { scale: number; x: number; y: number };
|
||||
|
||||
function loadPathPicker(): any {
|
||||
const factory = new Function(
|
||||
'window',
|
||||
'document',
|
||||
'localStorage',
|
||||
'fetch',
|
||||
'MobileDetection',
|
||||
`${accessoryJs}\nreturn PathPicker;`
|
||||
);
|
||||
return factory(jsdomWindow, doc, jsdomWindow.localStorage, vi.fn(), { isTouchDevice: () => false });
|
||||
}
|
||||
|
||||
describe('PathPicker image preview zoom', () => {
|
||||
let PathPicker: any;
|
||||
|
||||
beforeEach(() => {
|
||||
doc.body.replaceChildren();
|
||||
PathPicker = loadPathPicker();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
PathPicker?.closePreview?.(false);
|
||||
doc.body.replaceChildren();
|
||||
});
|
||||
|
||||
/** Open an image preview whose image has the given natural size (800x600 fills the box exactly). */
|
||||
const openImage = (natural = { width: 800, height: 600 }, name = 'shot.png') => {
|
||||
PathPicker.openPreview({ name, path: `/home/dev/${name}`, previewKind: 'image' });
|
||||
const overlay = PathPicker._previewOverlay as HTMLElement;
|
||||
const body = overlay.querySelector('.path-preview-body') as HTMLElement;
|
||||
body.getBoundingClientRect = () =>
|
||||
({
|
||||
...BOX,
|
||||
x: BOX.left,
|
||||
y: BOX.top,
|
||||
right: BOX.left + BOX.width,
|
||||
bottom: BOX.top + BOX.height,
|
||||
toJSON: () => ({}),
|
||||
}) as DOMRect;
|
||||
const image = body.querySelector('img.path-preview-image') as HTMLImageElement;
|
||||
Object.defineProperty(image, 'naturalWidth', { value: natural.width, configurable: true });
|
||||
Object.defineProperty(image, 'naturalHeight', { value: natural.height, configurable: true });
|
||||
const controls = overlay.querySelector('.path-preview-zoom') as HTMLElement;
|
||||
const button = (zoom: string) => controls.querySelector(`[data-zoom="${zoom}"]`) as HTMLButtonElement;
|
||||
return { overlay, body, image, controls, button };
|
||||
};
|
||||
|
||||
const fire = (target: EventTarget, type: string) => target.dispatchEvent(new jsdomWindow.Event(type));
|
||||
const state = (): ZoomState => PathPicker._previewImageZoom;
|
||||
|
||||
/** Where, in the unzoomed image, the given screen point lands under the current transform. */
|
||||
const imagePointUnder = (p: Point, s: ZoomState = state()): Point => ({
|
||||
x: (p.x - CENTRE.x - s.x) / s.scale,
|
||||
y: (p.y - CENTRE.y - s.y) / s.scale,
|
||||
});
|
||||
|
||||
const wheel = (target: EventTarget, init: Record<string, unknown>) => {
|
||||
const event = new jsdomWindow.WheelEvent('wheel', { bubbles: true, cancelable: true, ...init });
|
||||
target.dispatchEvent(event);
|
||||
return event;
|
||||
};
|
||||
|
||||
/** jsdom has no PointerEvent: a MouseEvent of the pointer type with a pointerId behaves the same. */
|
||||
const pointer = (target: EventTarget, type: string, id: number, p: Point) => {
|
||||
const event = new jsdomWindow.MouseEvent(type, { bubbles: true, cancelable: true, clientX: p.x, clientY: p.y });
|
||||
Object.defineProperty(event, 'pointerId', { value: id });
|
||||
target.dispatchEvent(event);
|
||||
};
|
||||
|
||||
const dblclick = (target: EventTarget, p: Point) =>
|
||||
target.dispatchEvent(
|
||||
new jsdomWindow.MouseEvent('dblclick', { bubbles: true, cancelable: true, clientX: p.x, clientY: p.y })
|
||||
);
|
||||
|
||||
it('shows the controls only once the image has loaded, and the buttons step the zoom', () => {
|
||||
const { image, controls, button } = openImage();
|
||||
expect(controls.hidden).toBe(true);
|
||||
|
||||
fire(image, 'load');
|
||||
expect(controls.hidden).toBe(false);
|
||||
expect(button('fit').textContent).toBe('100%');
|
||||
|
||||
button('in').click();
|
||||
expect(state().scale).toBeCloseTo(1.5);
|
||||
expect(button('fit').textContent).toBe('150%');
|
||||
expect(image.classList.contains('is-zoomed')).toBe(true);
|
||||
|
||||
button('fit').click();
|
||||
expect(state()).toMatchObject({ scale: 1 });
|
||||
expect(Math.abs(state().x) + Math.abs(state().y)).toBe(0);
|
||||
expect(button('fit').textContent).toBe('100%');
|
||||
expect(image.classList.contains('is-zoomed')).toBe(false);
|
||||
|
||||
button('out').click();
|
||||
expect(state().scale).toBe(1);
|
||||
});
|
||||
|
||||
it('wheel zoom keeps the image point under the cursor where it was', () => {
|
||||
const { body, image } = openImage();
|
||||
fire(image, 'load');
|
||||
|
||||
for (const cursor of [
|
||||
{ x: 300, y: 100 },
|
||||
{ x: 120, y: 220 },
|
||||
{ x: 390, y: 290 },
|
||||
]) {
|
||||
const before = imagePointUnder(cursor);
|
||||
const scaleBefore = state().scale;
|
||||
wheel(body, { deltaY: -100, clientX: cursor.x, clientY: cursor.y });
|
||||
expect(state().scale).toBeCloseTo(scaleBefore * 2 ** 0.2);
|
||||
const after = imagePointUnder(cursor);
|
||||
expect(after.x).toBeCloseTo(before.x);
|
||||
expect(after.y).toBeCloseTo(before.y);
|
||||
}
|
||||
expect(image.style.transform).toContain(`scale(${state().scale})`);
|
||||
});
|
||||
|
||||
it('pinch zooms by the finger distance around the pinch centre', () => {
|
||||
const { image } = openImage();
|
||||
fire(image, 'load');
|
||||
const centre = { x: 150, y: 150 };
|
||||
const before = imagePointUnder(centre);
|
||||
|
||||
pointer(image, 'pointerdown', 1, { x: 100, y: 150 });
|
||||
pointer(image, 'pointerdown', 2, { x: 200, y: 150 });
|
||||
pointer(image, 'pointermove', 2, { x: 300, y: 150 });
|
||||
|
||||
expect(state().scale).toBeCloseTo(2);
|
||||
const after = imagePointUnder(centre);
|
||||
expect(after.x).toBeCloseTo(before.x);
|
||||
expect(after.y).toBeCloseTo(before.y);
|
||||
|
||||
pointer(image, 'pointerup', 2, { x: 300, y: 150 });
|
||||
pointer(image, 'pointerup', 1, { x: 100, y: 150 });
|
||||
expect(state().pointers.size).toBe(0);
|
||||
});
|
||||
|
||||
it('pans a zoomed image only until its edge meets the viewport edge', () => {
|
||||
const { image } = openImage();
|
||||
fire(image, 'load');
|
||||
dblclick(image, CENTRE);
|
||||
expect(state().scale).toBe(2);
|
||||
|
||||
pointer(image, 'pointerdown', 1, CENTRE);
|
||||
pointer(image, 'pointermove', 1, { x: 5000, y: 5000 });
|
||||
// 800x600 drawn at 400x300, zoomed 2x to 800x600 in a 400x300 box: 200 and 150 of slack.
|
||||
expect(state()).toMatchObject({ x: 200, y: 150 });
|
||||
pointer(image, 'pointermove', 1, { x: -5000, y: -5000 });
|
||||
expect(state()).toMatchObject({ x: -200, y: -150 });
|
||||
pointer(image, 'pointerup', 1, { x: -5000, y: -5000 });
|
||||
|
||||
// Without zoom there is nothing to pan.
|
||||
dblclick(image, CENTRE);
|
||||
pointer(image, 'pointerdown', 1, CENTRE);
|
||||
pointer(image, 'pointermove', 1, { x: 300, y: 250 });
|
||||
expect(state()).toMatchObject({ scale: 1 });
|
||||
expect(Math.abs(state().x) + Math.abs(state().y)).toBe(0);
|
||||
});
|
||||
|
||||
it('keeps a small image in view: centred while it is smaller than the viewport, clamped to its own edges after', () => {
|
||||
// 100x50 is drawn at its own size (scale-down), not stretched to the 400x300 box.
|
||||
const { image } = openImage({ width: 100, height: 50 });
|
||||
fire(image, 'load');
|
||||
|
||||
dblclick(image, { x: 380, y: 280 });
|
||||
expect(state().scale).toBe(2);
|
||||
pointer(image, 'pointerdown', 1, CENTRE);
|
||||
pointer(image, 'pointermove', 1, { x: 5000, y: 5000 });
|
||||
// 200x100 still fits inside 400x300 on both axes: it stays centred.
|
||||
expect(Math.abs(state().x) + Math.abs(state().y)).toBe(0);
|
||||
pointer(image, 'pointerup', 1, { x: 5000, y: 5000 });
|
||||
|
||||
PathPicker._setPreviewImageZoom(6);
|
||||
pointer(image, 'pointerdown', 1, CENTRE);
|
||||
pointer(image, 'pointermove', 1, { x: 5000, y: 5000 });
|
||||
// 600x300 at 6x: 100 of horizontal slack, none vertically. The image box (400x300 at 6x) would
|
||||
// have allowed 1000 and 750, carrying the image entirely out of the viewport.
|
||||
expect(state().x).toBe(100);
|
||||
expect(Math.abs(state().y)).toBe(0);
|
||||
});
|
||||
|
||||
it('scales wheel zoom by deltaY, ignores a delta of 0 and caps one event', () => {
|
||||
const factor = (init: Record<string, unknown>) => PathPicker._previewWheelZoomFactor(init);
|
||||
expect(factor({ deltaY: 0, deltaX: 40 })).toBe(1);
|
||||
expect(factor({ deltaY: -4 })).toBeCloseTo(2 ** 0.008);
|
||||
expect(factor({ deltaY: -100 })).toBeCloseTo(2 ** 0.2);
|
||||
expect(factor({ deltaY: 100 })).toBeCloseTo(2 ** -0.2);
|
||||
// Lines (Firefox's mouse wheel) and ctrl+wheel (a trackpad pinch) are scaled up, not ignored.
|
||||
expect(factor({ deltaY: -3, deltaMode: 1 })).toBeCloseTo(2 ** 0.15);
|
||||
expect(factor({ deltaY: -10, ctrlKey: true })).toBeCloseTo(2 ** 0.2);
|
||||
expect(factor({ deltaY: -100000 })).toBeCloseTo(Math.SQRT2);
|
||||
expect(factor({ deltaY: 100000 })).toBeCloseTo(Math.SQRT1_2);
|
||||
|
||||
const { body, image } = openImage();
|
||||
fire(image, 'load');
|
||||
const zoom = vi.spyOn(PathPicker, '_setPreviewImageZoom');
|
||||
const sideways = wheel(body, { deltaY: 0, deltaX: 40, clientX: 300, clientY: 100 });
|
||||
expect(sideways.defaultPrevented).toBe(true);
|
||||
expect(zoom).not.toHaveBeenCalled();
|
||||
expect(state().scale).toBe(1);
|
||||
|
||||
// A trackpad scroll arrives as many small deltas; at 20% per event these 30 hit the 600% cap.
|
||||
for (let i = 0; i < 30; i++) wheel(body, { deltaY: -4, clientX: 300, clientY: 100 });
|
||||
expect(state().scale).toBeCloseTo(2 ** 0.24);
|
||||
expect(state().scale).toBeLessThan(1.25);
|
||||
});
|
||||
|
||||
it('hides the controls and stops zooming when the image fails to load', () => {
|
||||
const { body, image, controls } = openImage();
|
||||
fire(image, 'error');
|
||||
|
||||
expect(controls.hidden).toBe(true);
|
||||
expect(body.querySelector('.path-preview-error')?.textContent).toBe('Image preview failed to load');
|
||||
expect(PathPicker._previewImageZoom).toBeNull();
|
||||
|
||||
const zoom = vi.spyOn(PathPicker, '_setPreviewImageZoom');
|
||||
wheel(body, { deltaY: -100, clientX: 200, clientY: 150 });
|
||||
expect(zoom).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('closing drops the zoom state and every listener, and the next image starts at 100%', () => {
|
||||
const first = openImage();
|
||||
fire(first.image, 'load');
|
||||
dblclick(first.image, { x: 300, y: 100 });
|
||||
expect(state().scale).toBe(2);
|
||||
const transformAtClose = first.image.style.transform;
|
||||
|
||||
PathPicker.closePreview(false);
|
||||
expect(PathPicker._previewImageZoom).toBeNull();
|
||||
expect(PathPicker._previewImageCleanup).toBeNull();
|
||||
expect(first.overlay.isConnected).toBe(false);
|
||||
|
||||
const zoom = vi.spyOn(PathPicker, '_setPreviewImageZoom');
|
||||
wheel(first.body, { deltaY: -100, clientX: 200, clientY: 150 });
|
||||
dblclick(first.image, CENTRE);
|
||||
first.button('in').click();
|
||||
expect(zoom).not.toHaveBeenCalled();
|
||||
expect(first.image.style.transform).toBe(transformAtClose);
|
||||
zoom.mockRestore();
|
||||
|
||||
const second = openImage({ width: 800, height: 600 }, 'next.png');
|
||||
fire(second.image, 'load');
|
||||
expect(state()).toMatchObject({ scale: 1 });
|
||||
expect(second.button('fit').textContent).toBe('100%');
|
||||
expect(second.image.classList.contains('is-zoomed')).toBe(false);
|
||||
});
|
||||
|
||||
it('a late load or error from a replaced preview leaves the current one alone', () => {
|
||||
const stale = openImage({ width: 800, height: 600 }, 'old.png');
|
||||
const current = openImage({ width: 800, height: 600 }, 'new.png');
|
||||
fire(current.image, 'load');
|
||||
dblclick(current.image, { x: 300, y: 100 });
|
||||
const zoomed = { ...state() };
|
||||
|
||||
fire(stale.image, 'load');
|
||||
expect(state()).toMatchObject({ scale: zoomed.scale, x: zoomed.x, y: zoomed.y });
|
||||
|
||||
fire(stale.image, 'error');
|
||||
expect(current.body.querySelector('.path-preview-error')).toBeNull();
|
||||
expect(current.image.isConnected).toBe(true);
|
||||
expect(current.controls.hidden).toBe(false);
|
||||
expect(PathPicker._previewImageZoom).not.toBeNull();
|
||||
});
|
||||
|
||||
it('draws a small image at its own size rather than stretching it (scale-down)', () => {
|
||||
const rule = stylesCss.match(/\n\.path-preview-image \{([^}]*)\}/);
|
||||
expect(rule?.[1]).toMatch(/object-fit:\s*scale-down;/);
|
||||
expect(rule?.[1]).not.toMatch(/object-fit:\s*contain;/);
|
||||
});
|
||||
});
|
||||
@@ -302,13 +302,7 @@ describe('POST /api/cases/:name/rename (a linked case)', () => {
|
||||
const real = join(work, 'my-project');
|
||||
mkdirSync(real);
|
||||
writeFileSync(LINKED(), JSON.stringify({ proj: real }));
|
||||
const seeded = {
|
||||
caseOrder: ['a', 'proj', 'b'],
|
||||
lastUsedCase: 'proj',
|
||||
lastPickedCase: 'proj',
|
||||
caseSettings: { proj: { agentTeams: true, opusContext1m: false } },
|
||||
};
|
||||
writeFileSync(SETTINGS_PATH, JSON.stringify(seeded));
|
||||
writeFileSync(SETTINGS_PATH, JSON.stringify({ caseOrder: ['a', 'proj', 'b'], lastUsedCase: 'proj' }));
|
||||
// the registry reads fine but cannot be written: the rename must undo the folder move
|
||||
const spy = failRegistryWrite();
|
||||
let res;
|
||||
@@ -322,7 +316,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(seeded);
|
||||
expect(settings()).toEqual({ caseOrder: ['a', 'proj', 'b'], lastUsedCase: 'proj' });
|
||||
});
|
||||
|
||||
it('puts back only the keys it wrote, keeping a settings write made in between', async () => {
|
||||
@@ -330,38 +324,17 @@ describe('POST /api/cases/:name/rename (a linked case)', () => {
|
||||
const real = join(work, 'my-project');
|
||||
mkdirSync(real);
|
||||
writeFileSync(LINKED(), JSON.stringify({ proj: real }));
|
||||
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 } },
|
||||
}))
|
||||
);
|
||||
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 })));
|
||||
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',
|
||||
lastPickedCase: 'proj',
|
||||
caseSettings: { proj: entry, other: { agentTeams: false, opusContext1m: true } },
|
||||
agentSkillEnabled: true,
|
||||
});
|
||||
expect(settings()).toEqual({ caseOrder: ['a', 'proj', 'b'], lastUsedCase: 'proj', agentSkillEnabled: true });
|
||||
});
|
||||
|
||||
describe('when the registry changes after the first check', () => {
|
||||
|
||||
@@ -1,322 +0,0 @@
|
||||
/**
|
||||
* @fileoverview `DELETE /api/sessions/:id/files?path=`, the Files panel's delete button (salvaged
|
||||
* from #529).
|
||||
*
|
||||
* Real temp workspace, no fs mock, like test/routes/file-write-routes.test.ts: the thing under
|
||||
* test is what the route does to real files, and the defect it fixes (a delete of
|
||||
* `AGENTS.md -> CLAUDE.md` removed CLAUDE.md, because the confinement check hands back the
|
||||
* realpath) only shows against a real symlink. Each refusal also checks that nothing on disk moved.
|
||||
*
|
||||
* Uses app.inject() — no real HTTP ports needed.
|
||||
* Port: N/A (app.inject doesn't open ports)
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import {
|
||||
existsSync,
|
||||
lstatSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
realpathSync,
|
||||
rmSync,
|
||||
symlinkSync,
|
||||
writeFileSync,
|
||||
} from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { createRouteTestHarness, type RouteTestHarness } from './_route-test-utils.js';
|
||||
import { registerFileRoutes } from '../../src/web/routes/file-routes.js';
|
||||
import { SETTINGS_PATH } from '../../src/web/route-helpers.js';
|
||||
|
||||
describe('DELETE /api/sessions/:id/files (real fs)', () => {
|
||||
let harness: RouteTestHarness;
|
||||
let base: string;
|
||||
let workDir: string;
|
||||
let outsideDir: string;
|
||||
|
||||
const del = (path: string, h: RouteTestHarness = harness) =>
|
||||
h.app.inject({
|
||||
method: 'DELETE',
|
||||
url: `/api/sessions/${h.ctx._sessionId}/files?path=${encodeURIComponent(path)}`,
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
harness = await createRouteTestHarness(registerFileRoutes);
|
||||
// realpath: tmpdir() can hold a symlinked component (macOS /tmp), and the fixtures compare paths.
|
||||
base = realpathSync(mkdtempSync(join(tmpdir(), 'codeman-delete-')));
|
||||
workDir = join(base, 'ws');
|
||||
outsideDir = join(base, 'outside');
|
||||
mkdirSync(workDir);
|
||||
mkdirSync(outsideDir);
|
||||
harness.ctx._session.workingDir = workDir;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await harness.app.close();
|
||||
rmSync(base, { recursive: true, force: true });
|
||||
rmSync(SETTINGS_PATH, { force: true });
|
||||
});
|
||||
|
||||
it('deletes a regular file and answers the path it was given', async () => {
|
||||
mkdirSync(join(workDir, 'src'));
|
||||
writeFileSync(join(workDir, 'src', 'old.ts'), 'x');
|
||||
writeFileSync(join(workDir, 'keep.ts'), 'y');
|
||||
|
||||
const res = await del('src/old.ts');
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.json()).toEqual({ success: true, data: { path: 'src/old.ts' } });
|
||||
expect(existsSync(join(workDir, 'src', 'old.ts'))).toBe(false);
|
||||
expect(existsSync(join(workDir, 'src'))).toBe(true);
|
||||
expect(readFileSync(join(workDir, 'keep.ts'), 'utf8')).toBe('y');
|
||||
});
|
||||
|
||||
it('deletes a file whose name carries quotes and other characters a shell or a URL cares about', async () => {
|
||||
const name = "x'-alert(1)-' & #?.txt";
|
||||
writeFileSync(join(workDir, name), 'x');
|
||||
|
||||
const res = await del(name);
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(existsSync(join(workDir, name))).toBe(false);
|
||||
});
|
||||
|
||||
describe('symbolic links are refused, never followed', () => {
|
||||
it('leaves both the link and its target when asked to delete AGENTS.md -> CLAUDE.md', async () => {
|
||||
writeFileSync(join(workDir, 'CLAUDE.md'), '# rules');
|
||||
symlinkSync('CLAUDE.md', join(workDir, 'AGENTS.md'));
|
||||
|
||||
const res = await del('AGENTS.md');
|
||||
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(res.json().success).toBe(false);
|
||||
expect(res.json().error).toMatch(/symbolic link/i);
|
||||
expect(readFileSync(join(workDir, 'CLAUDE.md'), 'utf8')).toBe('# rules');
|
||||
expect(lstatSync(join(workDir, 'AGENTS.md')).isSymbolicLink()).toBe(true);
|
||||
});
|
||||
|
||||
it('refuses a link to a folder, which the listing shows as a file row', async () => {
|
||||
mkdirSync(join(workDir, 'real'));
|
||||
writeFileSync(join(workDir, 'real', 'a.txt'), 'a');
|
||||
symlinkSync('real', join(workDir, 'alias'));
|
||||
|
||||
const res = await del('alias');
|
||||
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(readFileSync(join(workDir, 'real', 'a.txt'), 'utf8')).toBe('a');
|
||||
expect(lstatSync(join(workDir, 'alias')).isSymbolicLink()).toBe(true);
|
||||
});
|
||||
|
||||
it('answers 404 for a link that leads out of the workspace, and leaves its target', async () => {
|
||||
writeFileSync(join(outsideDir, 'secret.txt'), 'secret');
|
||||
symlinkSync(join(outsideDir, 'secret.txt'), join(workDir, 'leak.txt'));
|
||||
|
||||
const res = await del('leak.txt');
|
||||
|
||||
expect(res.statusCode).toBe(404);
|
||||
expect(readFileSync(join(outsideDir, 'secret.txt'), 'utf8')).toBe('secret');
|
||||
});
|
||||
|
||||
it('deletes a plain file reached through a linked folder inside the workspace', async () => {
|
||||
mkdirSync(join(workDir, 'real'));
|
||||
writeFileSync(join(workDir, 'real', 'b.txt'), 'b');
|
||||
symlinkSync('real', join(workDir, 'alias'));
|
||||
|
||||
const res = await del('alias/b.txt');
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(existsSync(join(workDir, 'real', 'b.txt'))).toBe(false);
|
||||
expect(lstatSync(join(workDir, 'alias')).isSymbolicLink()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('refuses a file under .git with 403, also when .git is reached through a linked folder', async () => {
|
||||
mkdirSync(join(workDir, '.git'));
|
||||
writeFileSync(join(workDir, '.git', 'config'), '[core]');
|
||||
symlinkSync('.git', join(workDir, 'gitdir'));
|
||||
|
||||
for (const path of ['.git/config', 'gitdir/config']) {
|
||||
const res = await del(path);
|
||||
expect(res.statusCode).toBe(403);
|
||||
expect(res.json().success).toBe(false);
|
||||
}
|
||||
expect(readFileSync(join(workDir, '.git', 'config'), 'utf8')).toBe('[core]');
|
||||
});
|
||||
|
||||
it('refuses a sensitive file with 403', async () => {
|
||||
writeFileSync(join(workDir, '.env'), 'TOKEN=abc');
|
||||
|
||||
const res = await del('.env');
|
||||
|
||||
expect(res.statusCode).toBe(403);
|
||||
expect(readFileSync(join(workDir, '.env'), 'utf8')).toBe('TOKEN=abc');
|
||||
});
|
||||
|
||||
it('answers 404 for a path that climbs out of the workspace', async () => {
|
||||
writeFileSync(join(outsideDir, 'victim.txt'), 'v');
|
||||
|
||||
for (const path of ['../outside/victim.txt', join(outsideDir, 'victim.txt')]) {
|
||||
const res = await del(path);
|
||||
expect(res.statusCode).toBe(404);
|
||||
}
|
||||
expect(readFileSync(join(outsideDir, 'victim.txt'), 'utf8')).toBe('v');
|
||||
});
|
||||
|
||||
it('answers 404 for a file that does not exist', async () => {
|
||||
expect((await del('nope.txt')).statusCode).toBe(404);
|
||||
});
|
||||
|
||||
it('refuses a directory with 400', async () => {
|
||||
mkdirSync(join(workDir, 'src'));
|
||||
writeFileSync(join(workDir, 'src', 'a.ts'), 'a');
|
||||
|
||||
const res = await del('src');
|
||||
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(readFileSync(join(workDir, 'src', 'a.ts'), 'utf8')).toBe('a');
|
||||
});
|
||||
|
||||
it('refuses a missing or repeated path parameter with 400', async () => {
|
||||
const missing = await harness.app.inject({
|
||||
method: 'DELETE',
|
||||
url: `/api/sessions/${harness.ctx._sessionId}/files`,
|
||||
});
|
||||
expect(missing.statusCode).toBe(400);
|
||||
writeFileSync(join(workDir, 'a.txt'), 'a');
|
||||
const repeated = await harness.app.inject({
|
||||
method: 'DELETE',
|
||||
url: `/api/sessions/${harness.ctx._sessionId}/files?path=a.txt&path=a.txt`,
|
||||
});
|
||||
expect(repeated.statusCode).toBe(400);
|
||||
expect(existsSync(join(workDir, 'a.txt'))).toBe(true);
|
||||
});
|
||||
|
||||
it('refuses a remote (SSH) session before looking at the local twin of its folder', async () => {
|
||||
// The local folder of the same absolute name is exactly the hazard: validation would resolve
|
||||
// against it and the delete would land here while the user meant the remote file.
|
||||
writeFileSync(join(workDir, 'twin.txt'), 'local');
|
||||
harness.ctx._session.remote = {
|
||||
hostId: 'host-1',
|
||||
label: 'testhost',
|
||||
host: '192.0.2.10',
|
||||
username: 'j',
|
||||
remotePath: workDir,
|
||||
};
|
||||
|
||||
const res = await del('twin.txt');
|
||||
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(res.json().error).toMatch(/remote/i);
|
||||
expect(readFileSync(join(workDir, 'twin.txt'), 'utf8')).toBe('local');
|
||||
});
|
||||
|
||||
it('refuses an adopted session, whose folder is not a workspace of ours', async () => {
|
||||
writeFileSync(join(workDir, 'shared.txt'), 'tmp');
|
||||
harness.ctx._session.isAdopted = true;
|
||||
|
||||
const res = await del('shared.txt');
|
||||
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(res.json().error).toMatch(/adopted/i);
|
||||
expect(readFileSync(join(workDir, 'shared.txt'), 'utf8')).toBe('tmp');
|
||||
});
|
||||
|
||||
describe('a worktree outside the session folder', () => {
|
||||
let sibling: string;
|
||||
|
||||
beforeEach(() => {
|
||||
sibling = join(base, 'ws-feature');
|
||||
mkdirSync(join(sibling, 'src'), { recursive: true });
|
||||
writeFileSync(join(sibling, 'src', 'feature.ts'), 'feature');
|
||||
Object.assign(harness.ctx._session, {
|
||||
liveWorktree: { name: 'ws-feature', path: sibling, outside: true },
|
||||
});
|
||||
});
|
||||
|
||||
it('deletes an absolute path inside it, as the other file routes serve it', async () => {
|
||||
const res = await del(join(sibling, 'src', 'feature.ts'));
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(existsSync(join(sibling, 'src', 'feature.ts'))).toBe(false);
|
||||
});
|
||||
|
||||
it('does not reach it with a relative path, which resolves against the session folder only', async () => {
|
||||
const res = await del('../ws-feature/src/feature.ts');
|
||||
expect(res.statusCode).toBe(404);
|
||||
expect(existsSync(join(sibling, 'src', 'feature.ts'))).toBe(true);
|
||||
});
|
||||
|
||||
it('refuses a symlink in it that leads out of it, and leaves the target', async () => {
|
||||
writeFileSync(join(outsideDir, 'key.txt'), 'secret');
|
||||
symlinkSync(join(outsideDir, 'key.txt'), join(sibling, 'leak.txt'));
|
||||
|
||||
const res = await del(join(sibling, 'leak.txt'));
|
||||
|
||||
expect(res.statusCode).toBe(404);
|
||||
expect(readFileSync(join(outsideDir, 'key.txt'), 'utf8')).toBe('secret');
|
||||
});
|
||||
|
||||
it('reaches nothing there once it is no longer an outside worktree', async () => {
|
||||
Object.assign(harness.ctx._session, { liveWorktree: { name: 'ws-feature', path: sibling } });
|
||||
expect((await del(join(sibling, 'src', 'feature.ts'))).statusCode).toBe(404);
|
||||
expect(existsSync(join(sibling, 'src', 'feature.ts'))).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['workspace confinement is turned on', () => ({ attachmentConfineToWorkspace: true })],
|
||||
['its parent folder is blocked', () => ({ attachmentBlockedPaths: [base] })],
|
||||
])('deletes nothing there once %s', async (_label, settings) => {
|
||||
mkdirSync(dirname(SETTINGS_PATH), { recursive: true });
|
||||
writeFileSync(SETTINGS_PATH, JSON.stringify(settings()));
|
||||
|
||||
const res = await del(join(sibling, 'src', 'feature.ts'));
|
||||
|
||||
expect(res.statusCode).toBe(404);
|
||||
expect(readFileSync(join(sibling, 'src', 'feature.ts'), 'utf8')).toBe('feature');
|
||||
});
|
||||
});
|
||||
|
||||
describe('multi-user mode', () => {
|
||||
let prevMultiUser: string | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
prevMultiUser = process.env.CODEMAN_MULTIUSER;
|
||||
process.env.CODEMAN_MULTIUSER = '1';
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (prevMultiUser === undefined) delete process.env.CODEMAN_MULTIUSER;
|
||||
else process.env.CODEMAN_MULTIUSER = prevMultiUser;
|
||||
});
|
||||
|
||||
it("answers 404 for another user's session and deletes nothing", async () => {
|
||||
const bob = await createRouteTestHarness(registerFileRoutes, { authUser: { username: 'bob', role: 'user' } });
|
||||
try {
|
||||
bob.ctx._session.workingDir = workDir;
|
||||
(bob.ctx._session as unknown as { owner?: string }).owner = 'alice';
|
||||
writeFileSync(join(workDir, 'alice.txt'), 'mine');
|
||||
|
||||
const res = await del('alice.txt', bob);
|
||||
|
||||
expect(res.statusCode).toBe(404);
|
||||
expect(readFileSync(join(workDir, 'alice.txt'), 'utf8')).toBe('mine');
|
||||
} finally {
|
||||
await bob.app.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('lets the owner delete in their own session', async () => {
|
||||
const bob = await createRouteTestHarness(registerFileRoutes, { authUser: { username: 'bob', role: 'user' } });
|
||||
try {
|
||||
bob.ctx._session.workingDir = workDir;
|
||||
(bob.ctx._session as unknown as { owner?: string }).owner = 'bob';
|
||||
writeFileSync(join(workDir, 'bob.txt'), 'mine');
|
||||
|
||||
expect((await del('bob.txt', bob)).statusCode).toBe(200);
|
||||
expect(existsSync(join(workDir, 'bob.txt'))).toBe(false);
|
||||
} finally {
|
||||
await bob.app.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -155,6 +155,7 @@ const IDLE_VIEW: LocalModelInstallView = {
|
||||
developerTools: 'missing',
|
||||
runtime: { version: '0.40.15', installed: false, downloadBytes: 185971671, downloadGb: '0.2', unpackedGb: '0.6' },
|
||||
folder: '/Users/me/.codeman/local-model',
|
||||
library: [],
|
||||
actions: ['install'],
|
||||
};
|
||||
|
||||
@@ -182,6 +183,8 @@ function fakeService(): ServiceMock {
|
||||
launchRefusalMessage: vi.fn(async () => undefined),
|
||||
recommendOptions: vi.fn(async () => ({ runtimeDownloadBytes: 185971671 })),
|
||||
install: vi.fn(async () => OK_RESULT),
|
||||
use: vi.fn(async () => OK_RESULT),
|
||||
deleteLibraryModel: vi.fn(async () => OK_RESULT),
|
||||
cancel: vi.fn(async () => OK_RESULT),
|
||||
resume: vi.fn(async () => OK_RESULT),
|
||||
runtime: vi.fn(async () => OK_RESULT),
|
||||
@@ -555,6 +558,13 @@ describe('the install verbs', () => {
|
||||
/** Every verb as a request, with a body the route accepts. */
|
||||
const VERBS = [
|
||||
{ name: 'install', method: 'POST', url: '/api/local-model/install', payload: INSTALL_BODY },
|
||||
{ name: 'use', method: 'POST', url: '/api/local-model/use', payload: { modelId: 'qwen3.8:27b' } },
|
||||
{
|
||||
name: 'deleteLibraryModel',
|
||||
method: 'DELETE',
|
||||
url: '/api/local-model/models',
|
||||
payload: { modelId: 'qwen3.8:27b' },
|
||||
},
|
||||
{ name: 'cancel', method: 'POST', url: '/api/local-model/cancel', payload: undefined },
|
||||
{ name: 'resume', method: 'POST', url: '/api/local-model/resume', payload: {} },
|
||||
{ name: 'runtime', method: 'POST', url: '/api/local-model/runtime', payload: { action: 'stop' } },
|
||||
@@ -654,7 +664,18 @@ describe('the install verbs', () => {
|
||||
const f = fakes();
|
||||
const { app } = await harness(f.deps, { username: 'alice', role: 'admin' });
|
||||
await app.inject({ method: 'POST', url: '/api/local-model/install', payload: INSTALL_BODY });
|
||||
expect(f.service.install).toHaveBeenCalledWith(INSTALL_BODY, { username: 'alice', ip: '127.0.0.1' });
|
||||
expect(f.service.install).toHaveBeenCalledWith(INSTALL_BODY, { username: 'alice', ip: '127.0.0.1' }, []);
|
||||
});
|
||||
|
||||
it('hands the service the names of the live sessions on the model: another model would take the runtime from them', async () => {
|
||||
const f = fakes();
|
||||
const { app } = await harness(f.deps, undefined, [
|
||||
{ name: 'w1-app', customModel: { endpointId: 'local-ollama' } },
|
||||
{ name: 'w2-app' },
|
||||
{ name: 'w3-lib', customModel: { endpointId: 'office-gpu' } },
|
||||
]);
|
||||
await app.inject({ method: 'POST', url: '/api/local-model/install', payload: INSTALL_BODY });
|
||||
expect(f.service.install.mock.calls[0][2]).toEqual(['w1-app']);
|
||||
});
|
||||
|
||||
it('a row the server does not offer is a 400 with the service own sentence, as text and as data', async () => {
|
||||
@@ -925,6 +946,177 @@ describe('the install verbs', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('the library verbs', () => {
|
||||
const SESSIONS: SessionLike[] = [
|
||||
{ name: 'w1-app', customModel: { endpointId: 'local-ollama' } },
|
||||
{ name: 'w2-app' },
|
||||
{ name: 'w3-lib', customModel: { endpointId: 'office-gpu' } },
|
||||
];
|
||||
|
||||
describe('POST /api/local-model/use', () => {
|
||||
it('hands the service the model, who asked and the live sessions on the model', async () => {
|
||||
const f = fakes();
|
||||
const { app } = await harness(f.deps, { username: 'alice', role: 'admin' }, SESSIONS);
|
||||
await app.inject({ method: 'POST', url: '/api/local-model/use', payload: { modelId: 'gpt-oss:20b' } });
|
||||
expect(f.service.use).toHaveBeenCalledWith({ modelId: 'gpt-oss:20b' }, { username: 'alice', ip: '127.0.0.1' }, [
|
||||
'w1-app',
|
||||
]);
|
||||
});
|
||||
|
||||
it('a model that is not one of the checked ones is a 400 with the sentence, as text and as data', async () => {
|
||||
const f = fakes();
|
||||
f.service.use.mockResolvedValue({
|
||||
ok: false,
|
||||
status: 400,
|
||||
message: { text: LOCAL_MODEL_INSTALL_LABELS.refusedNotInLibrary },
|
||||
});
|
||||
const { app } = await harness(f.deps);
|
||||
const res = await app.inject({ method: 'POST', url: '/api/local-model/use', payload: { modelId: 'x:1b' } });
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(res.json()).toEqual({
|
||||
success: false,
|
||||
error: 'This model is not one of the checked models on this Mac.',
|
||||
errorCode: 'INVALID_INPUT',
|
||||
data: { message: { text: 'This model is not one of the checked models on this Mac.' } },
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
['no body', undefined],
|
||||
['no model', {}],
|
||||
['a path', { modelId: '../../etc/passwd' }],
|
||||
['another registry', { modelId: 'hf.co/someone/model:Q4' }],
|
||||
['a member it does not know', { modelId: 'gpt-oss:20b', context: 65536 }],
|
||||
])('refuses %s before the service is asked', async (_name, payload) => {
|
||||
const f = fakes();
|
||||
const { app } = await harness(f.deps);
|
||||
const res = await app.inject({ method: 'POST', url: '/api/local-model/use', ...(payload ? { payload } : {}) });
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(f.service.use).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE /api/local-model/models', () => {
|
||||
it('hands the service the model name and who asked', async () => {
|
||||
const f = fakes();
|
||||
const { app } = await harness(f.deps, { username: 'alice', role: 'admin' });
|
||||
await app.inject({ method: 'DELETE', url: '/api/local-model/models', payload: { modelId: 'gpt-oss:20b' } });
|
||||
expect(f.service.deleteLibraryModel).toHaveBeenCalledWith(
|
||||
{ modelId: 'gpt-oss:20b' },
|
||||
{ username: 'alice', ip: '127.0.0.1' }
|
||||
);
|
||||
// The whole installation is another route: it was not asked.
|
||||
expect(f.service.remove).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['no body', undefined],
|
||||
['a path', { modelId: '/Users/me/.codeman/local-model/models' }],
|
||||
['a path that climbs', { modelId: '../../etc/passwd' }],
|
||||
['a name with a slash', { modelId: 'library/qwen3.8:27b' }],
|
||||
['a name without a tag', { modelId: 'qwen3.8' }],
|
||||
['a path member', { modelId: 'gpt-oss:20b', path: '/' }],
|
||||
])('takes a model name and nothing else: refuses %s', async (_name, payload) => {
|
||||
const f = fakes();
|
||||
const { app } = await harness(f.deps);
|
||||
const res = await app.inject({
|
||||
method: 'DELETE',
|
||||
url: '/api/local-model/models',
|
||||
...(payload ? { payload } : {}),
|
||||
});
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(f.service.deleteLibraryModel).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('the model in use: 409 with the reason and the sentence', async () => {
|
||||
const f = fakes();
|
||||
f.service.deleteLibraryModel.mockResolvedValue({
|
||||
ok: false,
|
||||
status: 409,
|
||||
conflict: {
|
||||
reason: 'model-active',
|
||||
message: { text: LOCAL_MODEL_INSTALL_LABELS.conflictModelActive, vars: { model: 'Qwen3.8 27B' } },
|
||||
},
|
||||
});
|
||||
const { app } = await harness(f.deps);
|
||||
const res = await app.inject({
|
||||
method: 'DELETE',
|
||||
url: '/api/local-model/models',
|
||||
payload: { modelId: 'qwen3.8:27b' },
|
||||
});
|
||||
expect(res.statusCode).toBe(409);
|
||||
expect(res.json()).toEqual({
|
||||
success: false,
|
||||
error: 'Qwen3.8 27B is the model in use. Switch to another model first, or remove the local model.',
|
||||
errorCode: 'CONFLICT',
|
||||
data: {
|
||||
reason: 'model-active',
|
||||
message: {
|
||||
text: '{model} is the model in use. Switch to another model first, or remove the local model.',
|
||||
vars: { model: 'Qwen3.8 27B' },
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('a runtime that did not delete it: 500 with the sentence, as text and as data', async () => {
|
||||
const f = fakes();
|
||||
f.service.deleteLibraryModel.mockResolvedValue({
|
||||
ok: false,
|
||||
status: 500,
|
||||
message: { text: LOCAL_MODEL_INSTALL_LABELS.deleteFailed, vars: { model: 'GPT-OSS 20B' } },
|
||||
});
|
||||
const { app } = await harness(f.deps);
|
||||
const res = await app.inject({
|
||||
method: 'DELETE',
|
||||
url: '/api/local-model/models',
|
||||
payload: { modelId: 'gpt-oss:20b' },
|
||||
});
|
||||
expect(res.statusCode).toBe(500);
|
||||
expect(res.json()).toEqual({
|
||||
success: false,
|
||||
error: 'GPT-OSS 20B could not be deleted from this Mac.',
|
||||
errorCode: 'OPERATION_FAILED',
|
||||
data: { message: { text: '{model} could not be deleted from this Mac.', vars: { model: 'GPT-OSS 20B' } } },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/local-model marks the rows that are already on this Mac', () => {
|
||||
it('the model in use and a model a switch can take, at the context they were checked with', async () => {
|
||||
const second = { ...OK.pick!, modelId: 'gpt-oss:20b', modelLabel: 'GPT-OSS 20B', context: 65536 };
|
||||
const third = { ...OK.pick!, modelId: 'mimo-v2.6:9b-q4_k_m', modelLabel: 'MiMo V2.6 9B' };
|
||||
const f = fakes(MAC, { ...OK, alternatives: [second, third] });
|
||||
const row = (id: string, context: number, active: boolean) => ({
|
||||
id,
|
||||
label: id,
|
||||
context,
|
||||
active,
|
||||
verified: true,
|
||||
status: 'ready' as const,
|
||||
actions: active ? [] : ['use' as const],
|
||||
});
|
||||
f.service.view.mockResolvedValue({
|
||||
...IDLE_VIEW,
|
||||
state: 'installed',
|
||||
library: [row(OK.pick!.modelId, OK.pick!.context, true), row('gpt-oss:20b', 65536, false)],
|
||||
actions: ['start-session', 'stop', 'install', 'use', 'remove'],
|
||||
});
|
||||
const data = (await get(f.deps)).json().data;
|
||||
expect(data.recommendation.pick.library).toBe('active');
|
||||
expect(data.recommendation.alternatives[0].library).toBe('ready');
|
||||
expect(data.recommendation.alternatives[1]).not.toHaveProperty('library');
|
||||
// What recommend() answered is not written to.
|
||||
expect(OK.pick).not.toHaveProperty('library');
|
||||
});
|
||||
|
||||
it('with nothing on this Mac no row carries the mark', async () => {
|
||||
const data = (await get(fakes().deps)).json().data;
|
||||
expect(data.recommendation.pick).not.toHaveProperty('library');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('the routes over the real install service', () => {
|
||||
const STATE = () => dataPath('local-model.json');
|
||||
|
||||
|
||||
@@ -146,7 +146,7 @@ async function boot(who: Who, stored: Settings, server: Settings | null) {
|
||||
};
|
||||
var KeyboardHandler = {}, SwipeHandler = {}, DeepgramProvider = {}, NotificationManager = function () {};
|
||||
var KeyboardAccessoryBar = { setMode() {} };
|
||||
var VoiceInput = { SILENCE_TIMEOUT_SECONDS: [0, 3, 6, 15, 30], _getDeepgramConfig: () => ({}), _saveDeepgramConfig() {}, refreshClaudeStatus: () => Promise.resolve() };
|
||||
var VoiceInput = { _getDeepgramConfig: () => ({}), _saveDeepgramConfig() {}, refreshClaudeStatus: () => Promise.resolve() };
|
||||
var FocusTrap = function () { this.activate = () => {}; this.deactivate = () => {}; };
|
||||
var DEFAULT_VOICE_KEYTERMS = '';`,
|
||||
...SCRIPTS,
|
||||
|
||||
@@ -159,7 +159,7 @@ async function boot(
|
||||
};
|
||||
var KeyboardHandler = {}, SwipeHandler = {}, DeepgramProvider = {}, NotificationManager = function () {};
|
||||
var KeyboardAccessoryBar = { setMode() {} };
|
||||
var VoiceInput = { SILENCE_TIMEOUT_SECONDS: [0, 3, 6, 15, 30], _getDeepgramConfig: () => ({}), _saveDeepgramConfig() {}, refreshClaudeStatus: () => Promise.resolve() };
|
||||
var VoiceInput = { _getDeepgramConfig: () => ({}), _saveDeepgramConfig() {}, refreshClaudeStatus: () => Promise.resolve() };
|
||||
var FocusTrap = function () { this.activate = () => {}; this.deactivate = () => {}; };
|
||||
var DEFAULT_VOICE_KEYTERMS = '';`,
|
||||
read('constants.js'),
|
||||
@@ -526,10 +526,6 @@ 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) {
|
||||
|
||||
@@ -1,138 +0,0 @@
|
||||
/**
|
||||
* @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 });
|
||||
});
|
||||
});
|
||||
@@ -1,87 +0,0 @@
|
||||
/**
|
||||
* @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);
|
||||
});
|
||||
});
|
||||
@@ -1,331 +0,0 @@
|
||||
/**
|
||||
* @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');
|
||||
});
|
||||
});
|
||||
@@ -125,7 +125,7 @@ async function boot(device: Device = 'desktop', stored?: Settings) {
|
||||
};
|
||||
var KeyboardHandler = {}, SwipeHandler = {}, DeepgramProvider = {}, NotificationManager = function () {};
|
||||
var KeyboardAccessoryBar = { setMode() {} };
|
||||
var VoiceInput = { SILENCE_TIMEOUT_SECONDS: [0, 3, 6, 15, 30], _getDeepgramConfig: () => ({}), _saveDeepgramConfig() {}, refreshClaudeStatus: () => Promise.resolve() };
|
||||
var VoiceInput = { _getDeepgramConfig: () => ({}), _saveDeepgramConfig() {}, refreshClaudeStatus: () => Promise.resolve() };
|
||||
var FocusTrap = function () { this.activate = () => {}; this.deactivate = () => {}; };
|
||||
var DEFAULT_VOICE_KEYTERMS = '';`,
|
||||
...SCRIPTS,
|
||||
|
||||
@@ -1,439 +0,0 @@
|
||||
import fs from 'node:fs';
|
||||
import vm from 'node:vm';
|
||||
import { expect, test } from 'vitest';
|
||||
const source = fs.readFileSync(new URL('../src/web/public/voice-input.js', import.meta.url), 'utf8');
|
||||
function result(text, final = true) {
|
||||
return Object.assign([{ transcript: text }], { isFinal: final });
|
||||
}
|
||||
function harness({
|
||||
platform = 'Android',
|
||||
ua = 'Mozilla/5.0 (Linux; Android 15) Chrome/141.0',
|
||||
touch = 5,
|
||||
delay = 0,
|
||||
} = {}) {
|
||||
let now = 1000,
|
||||
next = 0,
|
||||
starts = 0,
|
||||
mic = 0;
|
||||
const timers = new Map(),
|
||||
frames = new Map(),
|
||||
inserted = [],
|
||||
previews = [],
|
||||
attrs = {};
|
||||
const bars = Array.from({ length: 5 }, () => ({
|
||||
active: false,
|
||||
classList: {
|
||||
toggle(_name, on) {
|
||||
this.owner.active = on;
|
||||
},
|
||||
},
|
||||
}));
|
||||
for (const bar of bars) bar.classList.owner = bar;
|
||||
const meter = {
|
||||
children: bars,
|
||||
setAttribute(k, v) {
|
||||
attrs[k] = v;
|
||||
},
|
||||
};
|
||||
class Recognition {
|
||||
start() {
|
||||
starts++;
|
||||
this.results = [];
|
||||
}
|
||||
stop() {}
|
||||
abort() {}
|
||||
// Chromium Android promotes every native partial into a new final when continuous=true.
|
||||
partial(text) {
|
||||
if (this.continuous) this.results.push(result(text));
|
||||
else this.results = [result(text, false)];
|
||||
this.onresult({ resultIndex: this.continuous ? this.results.length - 1 : 0, results: this.results });
|
||||
}
|
||||
final(text) {
|
||||
if (this.continuous) this.results.push(result(text));
|
||||
else this.results = [result(text)];
|
||||
this.onresult({ resultIndex: this.continuous ? this.results.length - 1 : 0, results: this.results });
|
||||
}
|
||||
}
|
||||
const context = vm.createContext({
|
||||
console,
|
||||
window: { SpeechRecognition: Recognition },
|
||||
navigator: {
|
||||
userAgent: ua,
|
||||
platform: platform === 'Windows' ? 'Win32' : 'Linux aarch64',
|
||||
userAgentData: { platform },
|
||||
maxTouchPoints: touch,
|
||||
mediaDevices: {
|
||||
getUserMedia() {
|
||||
mic++;
|
||||
throw Error('second microphone capture');
|
||||
},
|
||||
},
|
||||
},
|
||||
localStorage: { getItem: () => JSON.stringify({ silenceTimeout: delay }) },
|
||||
app: { showToast() {} },
|
||||
WebSocket: { OPEN: 1 },
|
||||
Date: class extends Date {
|
||||
static now() {
|
||||
return now;
|
||||
}
|
||||
},
|
||||
setTimeout(fn, ms) {
|
||||
timers.set(++next, { fn, ms });
|
||||
return next;
|
||||
},
|
||||
clearTimeout: (id) => timers.delete(id),
|
||||
clearInterval() {},
|
||||
requestAnimationFrame(fn) {
|
||||
frames.set(++next, fn);
|
||||
return next;
|
||||
},
|
||||
cancelAnimationFrame: (id) => frames.delete(id),
|
||||
});
|
||||
const voice = vm.runInContext(source + '\nVoiceInput', context);
|
||||
for (const name of [
|
||||
'_updateButtons',
|
||||
'_startDurationTimer',
|
||||
'_stopDurationTimer',
|
||||
'_hidePreview',
|
||||
'_hideVoiceSendBtn',
|
||||
])
|
||||
voice[name] = () => {};
|
||||
voice.previewEl = { querySelector: () => meter, remove() {} };
|
||||
voice._showPreview = (text) => previews.push(text);
|
||||
voice._insertText = (text) => inserted.push(text);
|
||||
voice._initRecognition();
|
||||
return {
|
||||
voice,
|
||||
inserted,
|
||||
previews,
|
||||
bars,
|
||||
attrs,
|
||||
timers,
|
||||
frames,
|
||||
get starts() {
|
||||
return starts;
|
||||
},
|
||||
get mic() {
|
||||
return mic;
|
||||
},
|
||||
frame(delta = 120) {
|
||||
now += delta;
|
||||
const jobs = [...frames.values()];
|
||||
frames.clear();
|
||||
for (const fn of jobs) fn(now);
|
||||
},
|
||||
event(results, index = 0) {
|
||||
voice._onWebSpeechResult({ results, resultIndex: index });
|
||||
},
|
||||
finish() {
|
||||
voice.stop();
|
||||
voice._onWebSpeechEnd();
|
||||
},
|
||||
};
|
||||
}
|
||||
test('Android growing partial phrases and final insert exactly five words', () => {
|
||||
const h = harness();
|
||||
h.voice._startWebSpeech();
|
||||
for (const phrase of ['one', 'one two', 'one two three', 'one two three four', 'one two three four five'])
|
||||
h.voice.recognition.partial(phrase);
|
||||
h.voice.recognition.final('one two three four five');
|
||||
h.finish();
|
||||
expect(h.inserted).toEqual(['one two three four five']);
|
||||
expect(h.mic).toBe(0);
|
||||
});
|
||||
test('Android desktop-site mode also avoids continuous partial-to-final promotion', () => {
|
||||
const h = harness({ platform: 'Linux', ua: 'Mozilla/5.0 (X11; Linux x86_64) Chrome/141.0' });
|
||||
expect(h.voice.recognition.continuous).toBe(false);
|
||||
});
|
||||
test('Windows keeps continuous recognition and repeated snapshots do not append twice', () => {
|
||||
const h = harness({ platform: 'Windows', ua: 'Mozilla/5.0 (Windows NT 10.0) Chrome/141.0' });
|
||||
h.voice._startWebSpeech();
|
||||
expect(h.voice.recognition.continuous).toBe(true);
|
||||
h.event([result('yes yes'), result('do it', false)], 0);
|
||||
h.event([result('yes yes'), result('do it', false)], 0);
|
||||
h.event([result('yes yes'), result('do it')], 1);
|
||||
h.event([result('yes yes'), result('do it')], 0);
|
||||
h.finish();
|
||||
expect(h.inserted).toEqual(['yes yes do it']);
|
||||
});
|
||||
test('an interim correction replaces the previous text, including removed interim results', () => {
|
||||
const h = harness();
|
||||
h.voice._startWebSpeech();
|
||||
h.event([result('keep this'), result('wrong', false)]);
|
||||
h.event([result('keep this'), result('correct', false)], 1);
|
||||
expect(h.previews.at(-1)).toBe('keep this correct');
|
||||
h.event([result('keep this')], 1);
|
||||
h.finish();
|
||||
expect(h.inserted).toEqual(['keep this']);
|
||||
});
|
||||
for (const delay of [0, 3, 6, 15, 30])
|
||||
test(`Android preserves intentional repeated phrases across native ends with ${delay}s silence setting`, () => {
|
||||
const h = harness({ delay });
|
||||
h.voice._startWebSpeech();
|
||||
h.voice.recognition.final('go go');
|
||||
h.voice._onWebSpeechEnd();
|
||||
expect(h.starts).toBe(2);
|
||||
h.voice.recognition.partial('go go');
|
||||
h.voice.recognition.final('go go');
|
||||
h.finish();
|
||||
expect(h.inserted).toEqual(['go go go go']);
|
||||
});
|
||||
test('speech events animate bars, silence clears them, and Stop cancels animation', () => {
|
||||
const h = harness();
|
||||
h.voice._startWebSpeech();
|
||||
expect(h.bars.filter((b) => b.active).length).toBe(0);
|
||||
expect(typeof h.voice.recognition.onsoundstart).toBe('function');
|
||||
h.voice.recognition.onsoundstart();
|
||||
h.frame();
|
||||
const count = h.bars.filter((b) => b.active).length;
|
||||
expect(count).toBeGreaterThan(0);
|
||||
h.frame(300);
|
||||
expect(h.bars.filter((b) => b.active).length).not.toBe(count);
|
||||
h.voice.recognition.onsoundend();
|
||||
h.frame();
|
||||
expect(h.bars.filter((b) => b.active).length).toBe(0);
|
||||
h.voice.recognition.onspeechstart();
|
||||
h.frame();
|
||||
expect(h.bars.some((b) => b.active)).toBe(true);
|
||||
h.finish();
|
||||
expect(h.frames.size).toBe(0);
|
||||
expect(h.mic).toBe(0);
|
||||
});
|
||||
test('engines without sound events briefly animate on transcript updates', () => {
|
||||
const h = harness();
|
||||
h.voice._startWebSpeech();
|
||||
h.voice.recognition.partial('hello');
|
||||
h.frame();
|
||||
expect(h.bars.some((b) => b.active)).toBe(true);
|
||||
h.frame(1000);
|
||||
expect(h.bars.filter((b) => b.active).length).toBe(0);
|
||||
h.finish();
|
||||
});
|
||||
test('a new dictation starts with no words from the previous dictation', () => {
|
||||
const h = harness();
|
||||
h.voice._startWebSpeech();
|
||||
h.voice.recognition.final('first');
|
||||
h.finish();
|
||||
h.voice._startWebSpeech();
|
||||
h.voice.recognition.final('second');
|
||||
h.finish();
|
||||
expect(h.inserted).toEqual(['first', 'second']);
|
||||
});
|
||||
test('default 3-second silence deadline does not stop Android after a 750ms stable interim', () => {
|
||||
const h = harness({ delay: 3 });
|
||||
h.voice._startWebSpeech();
|
||||
h.voice.recognition.partial('five words spoken very clearly');
|
||||
expect([...h.timers.values()].map((t) => t.ms)).toEqual([3000]);
|
||||
});
|
||||
test('interim-only native end cannot be reinserted by an old stability callback', () => {
|
||||
const h = harness({ delay: 3 });
|
||||
h.voice._startWebSpeech();
|
||||
h.voice.recognition.partial('five words spoken very clearly');
|
||||
h.voice._onWebSpeechEnd();
|
||||
expect(h.starts).toBe(2);
|
||||
// Execute the callbacks that were queued before the native end, in deadline order.
|
||||
const pending = [...h.timers.entries()].sort((a, b) => a[1].ms - b[1].ms);
|
||||
for (const [id, timer] of pending) {
|
||||
if (h.timers.delete(id)) timer.fn();
|
||||
}
|
||||
h.voice._onWebSpeechEnd();
|
||||
expect(h.inserted).toEqual(['five words spoken very clearly']);
|
||||
});
|
||||
|
||||
function stopHarness() {
|
||||
const timers = new Map(),
|
||||
inserted = [],
|
||||
previews = [],
|
||||
toasts = [];
|
||||
let next = 0,
|
||||
micRequests = 0,
|
||||
stops = 0,
|
||||
starts = 0;
|
||||
const context = vm.createContext({
|
||||
console,
|
||||
app: { showToast: (text) => toasts.push(text) },
|
||||
navigator: {
|
||||
userAgent: 'Mozilla/5.0 (Linux; Android 15) Chrome/141.0',
|
||||
mediaDevices: {
|
||||
getUserMedia: async () => {
|
||||
micRequests++;
|
||||
return { getTracks: () => [] };
|
||||
},
|
||||
},
|
||||
},
|
||||
localStorage: { getItem: () => JSON.stringify({ silenceTimeout: 0 }) },
|
||||
WebSocket: { OPEN: 1 },
|
||||
setTimeout: (fn, ms) => {
|
||||
timers.set(++next, { fn, ms });
|
||||
return next;
|
||||
},
|
||||
clearTimeout: (id) => timers.delete(id),
|
||||
clearInterval() {},
|
||||
requestAnimationFrame() {
|
||||
return 1;
|
||||
},
|
||||
cancelAnimationFrame() {},
|
||||
});
|
||||
const [voice, claude] = vm.runInContext(source + '\n[VoiceInput,ClaudeVoiceProvider]', context);
|
||||
voice.recognition = {
|
||||
start() {
|
||||
starts++;
|
||||
},
|
||||
stop() {
|
||||
stops++;
|
||||
},
|
||||
abort() {},
|
||||
};
|
||||
voice.supported = true;
|
||||
for (const name of [
|
||||
'_updateButtons',
|
||||
'_startDurationTimer',
|
||||
'_stopDurationTimer',
|
||||
'_startLevelMeter',
|
||||
'_stopLevelMeter',
|
||||
'_hidePreview',
|
||||
'_hideVoiceSendBtn',
|
||||
])
|
||||
voice[name] = () => {};
|
||||
voice._showPreview = (text) => previews.push(text);
|
||||
voice._insertText = (text) => inserted.push(text);
|
||||
return {
|
||||
voice,
|
||||
claude,
|
||||
timers,
|
||||
inserted,
|
||||
previews,
|
||||
toasts,
|
||||
get micRequests() {
|
||||
return micRequests;
|
||||
},
|
||||
get stops() {
|
||||
return stops;
|
||||
},
|
||||
get starts() {
|
||||
return starts;
|
||||
},
|
||||
};
|
||||
}
|
||||
function stopResult(voice, text, final = true) {
|
||||
voice._onWebSpeechResult({ resultIndex: 0, results: [Object.assign([{ transcript: text }], { isFinal: final })] });
|
||||
}
|
||||
test('browser recognition owns the microphone without a competing cosmetic recorder', async () => {
|
||||
const h = stopHarness();
|
||||
h.voice._startWebSpeech();
|
||||
await Promise.resolve();
|
||||
expect(h.starts).toBe(1);
|
||||
expect(h.micRequests).toBe(0);
|
||||
stopResult(h.voice, 'live Android words', false);
|
||||
expect(h.previews.at(-1)).toBe('live Android words');
|
||||
});
|
||||
test('tap Stop accepts a delayed final transcript and inserts once on end', () => {
|
||||
const h = stopHarness();
|
||||
h.voice._startWebSpeech();
|
||||
h.voice.stop();
|
||||
expect(h.stops).toBe(1);
|
||||
expect(h.inserted).toEqual([]);
|
||||
stopResult(h.voice, 'words arriving after stop');
|
||||
h.voice._onWebSpeechEnd();
|
||||
h.voice._onWebSpeechEnd();
|
||||
expect(h.inserted).toEqual(['words arriving after stop']);
|
||||
expect(h.voice.isRecording).toBe(false);
|
||||
expect(h.starts).toBe(1);
|
||||
});
|
||||
test('repeated Stop does not finalize early or restart recognition', () => {
|
||||
const h = stopHarness();
|
||||
h.voice._startWebSpeech();
|
||||
h.voice.stop();
|
||||
h.voice.stop();
|
||||
stopResult(h.voice, 'last phrase');
|
||||
h.voice._onWebSpeechEnd();
|
||||
expect(h.inserted).toEqual(['last phrase']);
|
||||
expect(h.stops).toBe(1);
|
||||
expect(h.starts).toBe(1);
|
||||
});
|
||||
test('stop timeout preserves interim words if the browser never ends', () => {
|
||||
const h = stopHarness();
|
||||
h.voice._startWebSpeech();
|
||||
stopResult(h.voice, 'interim words', false);
|
||||
h.voice.stop();
|
||||
const timeout = [...h.timers.values()].find((t) => t.ms === 3000);
|
||||
expect(timeout, 'bounded wait for the browser final result').toBeTruthy();
|
||||
timeout.fn();
|
||||
expect(h.inserted).toEqual(['interim words']);
|
||||
expect(h.voice.isRecording).toBe(false);
|
||||
stopResult(h.voice, 'too late');
|
||||
expect(h.inserted).toEqual(['interim words']);
|
||||
});
|
||||
test('manual thinking pauses still restart natural recognition ends and retain words', () => {
|
||||
const h = stopHarness();
|
||||
h.voice._startWebSpeech();
|
||||
stopResult(h.voice, 'first phrase', false);
|
||||
h.voice._onWebSpeechEnd();
|
||||
stopResult(h.voice, 'second phrase');
|
||||
h.voice.stop();
|
||||
h.voice._onWebSpeechEnd();
|
||||
expect(h.inserted).toEqual(['first phrase second phrase']);
|
||||
expect(h.starts).toBe(2);
|
||||
});
|
||||
test('Claude still accepts its final transcript after finalize', () => {
|
||||
const h = stopHarness();
|
||||
h.voice.isRecording = true;
|
||||
h.voice._activeProvider = 'claude';
|
||||
h.claude._ws = { readyState: 1 };
|
||||
let finalized = 0;
|
||||
h.claude.stop = () => finalized++;
|
||||
h.voice.stop();
|
||||
expect(h.voice.isRecording).toBe(true);
|
||||
h.voice._accumulatedFinal = 'Claude final';
|
||||
h.voice.stop(true);
|
||||
expect(h.inserted).toEqual(['Claude final']);
|
||||
expect(finalized).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
test('failed start ends immediately with an error instead of waiting for a final result', () => {
|
||||
const h = stopHarness();
|
||||
h.voice.recognition.start = () => {
|
||||
throw new Error('fixture failure');
|
||||
};
|
||||
h.voice._startWebSpeech();
|
||||
expect(h.voice.isRecording).toBe(false);
|
||||
expect(h.toasts[0]).toMatch(/fixture failure/);
|
||||
expect(h.timers.size).toBe(0);
|
||||
});
|
||||
test('failed stop still inserts available words and clears the finishing timer', () => {
|
||||
const h = stopHarness();
|
||||
h.voice._startWebSpeech();
|
||||
stopResult(h.voice, 'existing words', false);
|
||||
h.voice.recognition.stop = () => {
|
||||
throw new Error('already stopped');
|
||||
};
|
||||
h.voice.stop();
|
||||
expect(h.inserted).toEqual(['existing words']);
|
||||
expect(h.voice.isRecording).toBe(false);
|
||||
expect(h.timers.size).toBe(0);
|
||||
});
|
||||
test('cleanup during finishing releases recognition and cannot insert a second result', () => {
|
||||
const h = stopHarness();
|
||||
h.voice._startWebSpeech();
|
||||
stopResult(h.voice, 'keep these words', false);
|
||||
h.voice.stop();
|
||||
h.voice.cleanup();
|
||||
expect(h.voice.isRecording).toBe(false);
|
||||
expect(h.timers.size).toBe(0);
|
||||
stopResult(h.voice, 'stale result');
|
||||
h.voice._onWebSpeechEnd();
|
||||
expect(h.inserted).toEqual(['keep these words']);
|
||||
expect(h.voice.recognition).toBe(null);
|
||||
});
|
||||
test('fatal browser error ends immediately without retrying or dropping existing words', () => {
|
||||
const h = stopHarness();
|
||||
h.voice._startWebSpeech();
|
||||
stopResult(h.voice, 'existing words', false);
|
||||
h.voice._onWebSpeechError({ error: 'network' });
|
||||
h.voice._onWebSpeechEnd();
|
||||
expect(h.voice.isRecording).toBe(false);
|
||||
expect(h.inserted).toEqual(['existing words']);
|
||||
expect(h.starts).toBe(1);
|
||||
expect(h.timers.size).toBe(0);
|
||||
expect(h.toasts[0]).toMatch(/internet/);
|
||||
});
|
||||
@@ -1,81 +0,0 @@
|
||||
// Real Chromium rendering/input; native Android/Windows recognition is simulated.
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { chromium, type Browser } from 'playwright';
|
||||
import { afterAll, beforeAll, expect, it } from 'vitest';
|
||||
const source = readFileSync(new URL('../src/web/public/voice-input.js', import.meta.url), 'utf8');
|
||||
const styles = readFileSync(new URL('../src/web/public/styles.css', import.meta.url), 'utf8');
|
||||
let browser: Browser;
|
||||
beforeAll(async () => {
|
||||
browser = await chromium.launch();
|
||||
});
|
||||
afterAll(async () => {
|
||||
await browser?.close();
|
||||
});
|
||||
it.each([
|
||||
[390, 'Android', 'Mozilla/5.0 (Linux; Android 15) Chrome/141.0'],
|
||||
[820, 'Linux', 'Mozilla/5.0 (X11; Linux x86_64) Chrome/141.0'],
|
||||
[1292, 'Windows', 'Mozilla/5.0 (Windows NT 10.0) Chrome/141.0'],
|
||||
] as const)('dictation at %ipx (%s)', async (width, platform, ua) => {
|
||||
const page = await browser.newPage({ viewport: { width, height: 900 }, hasTouch: true, userAgent: ua });
|
||||
const errors: string[] = [];
|
||||
page.on('pageerror', (e) => errors.push(e.message));
|
||||
await page.setContent(
|
||||
'<button id="mic" style="position:fixed;top:20px;left:20px">Microphone</button><div class="voice-compose-overlay"><textarea class="paste-textarea" aria-label="Transcript"></textarea></div>'
|
||||
);
|
||||
await page.addStyleTag({ content: styles });
|
||||
await page.addScriptTag({
|
||||
content: `const platform = ${JSON.stringify(platform)};
|
||||
Object.defineProperty(navigator,'userAgentData',{value:{platform},configurable:true});
|
||||
Object.defineProperty(navigator,'platform',{value:platform==='Windows'?'Win32':'Linux x86_64',configurable:true});
|
||||
window.micRequests=0;
|
||||
Object.defineProperty(navigator,'mediaDevices',{value:{getUserMedia(){window.micRequests++;return Promise.reject(Error('Unexpected second microphone'));}},configurable:true});
|
||||
window.SpeechRecognition=class{
|
||||
start(){this.results=[];}
|
||||
partial(text){this.text=text;const r=Object.assign([{transcript:text}],{isFinal:platform!=='Windows'&&this.continuous});if(r.isFinal)this.results.push(r);else this.results=[r];this.onresult({resultIndex:r.isFinal?this.results.length-1:0,results:this.results});}
|
||||
final(text){this.text=text;const r=Object.assign([{transcript:text}],{isFinal:true});if(platform!=='Windows'&&this.continuous)this.results.push(r);else this.results=[r];this.onresult({resultIndex:0,results:this.results});}
|
||||
stop(){setTimeout(()=>{this.final(this.text);this.onend();},50);}
|
||||
abort(){}
|
||||
};
|
||||
window.app={activeSessionId:'fixture',showToast(){}};
|
||||
`,
|
||||
});
|
||||
await page.addScriptTag({
|
||||
content:
|
||||
source +
|
||||
`\nwindow.voice=VoiceInput;VoiceInput._getDeepgramConfig=()=>({provider:'webspeech',insertMode:'compose',silenceTimeout:0});document.getElementById('mic').onclick=()=>VoiceInput.toggle();`,
|
||||
});
|
||||
await page.click('#mic');
|
||||
expect(await page.evaluate(() => (window as any).voice.recognition.continuous)).toBe(platform === 'Windows');
|
||||
await page.evaluate(() => (window as any).voice.recognition.onsoundstart());
|
||||
await page.waitForFunction(() => document.querySelectorAll('.voice-level-bar.active').length > 0);
|
||||
const counts = new Set();
|
||||
for (let i = 0; i < 8; i++) {
|
||||
await page.waitForTimeout(90);
|
||||
counts.add(await page.locator('.voice-level-bar.active').count());
|
||||
}
|
||||
expect(counts.size, 'speech bars must animate').toBeGreaterThan(1);
|
||||
await page.evaluate(() => (window as any).voice.recognition.onsoundend());
|
||||
await page.waitForFunction(() => document.querySelectorAll('.voice-level-bar.active').length === 0);
|
||||
await page.evaluate(() => {
|
||||
const voice = (window as any).voice;
|
||||
for (const text of ['one', 'one two', 'one two three', 'one two three four', 'one two three four five'])
|
||||
voice.recognition.partial(text);
|
||||
voice.recognition.final('one two three four five');
|
||||
});
|
||||
expect(await page.evaluate(() => (window as any).voice._previewTextNode.textContent)).toBe('one two three four five');
|
||||
await page.click('#mic');
|
||||
await page.waitForFunction(() => document.querySelector('textarea')?.value === 'one two three four five');
|
||||
expect(await page.evaluate(() => (window as any).voice.isRecording)).toBe(false);
|
||||
await page.click('#mic');
|
||||
await page.evaluate(() => {
|
||||
const voice = (window as any).voice;
|
||||
voice.recognition.final('go go');
|
||||
voice.recognition.onend();
|
||||
voice.recognition.partial('go go');
|
||||
});
|
||||
await page.click('#mic');
|
||||
await page.waitForFunction(() => document.querySelector('textarea')?.value === 'go go go go');
|
||||
expect(await page.evaluate(() => (window as any).micRequests)).toBe(0);
|
||||
expect(errors).toEqual([]);
|
||||
await page.close();
|
||||
});
|
||||
@@ -1,394 +0,0 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import vm from 'node:vm';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { SettingsUpdateSchema } from '../src/web/schemas.js';
|
||||
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
const source = readFileSync(new URL('../src/web/public/voice-input.js', import.meta.url), 'utf8');
|
||||
function load(silenceTimeout?: unknown, overrides: Record<string, unknown> = {}) {
|
||||
vi.useRealTimers();
|
||||
vi.useFakeTimers();
|
||||
const context = vm.createContext({
|
||||
// The context's own Date ignores fake timers; run lengths must follow them.
|
||||
Date,
|
||||
setTimeout,
|
||||
clearTimeout,
|
||||
setInterval,
|
||||
clearInterval,
|
||||
requestAnimationFrame: () => 1,
|
||||
cancelAnimationFrame: () => {},
|
||||
app: { showToast: vi.fn() },
|
||||
console,
|
||||
navigator: {},
|
||||
WebSocket: { OPEN: 1 },
|
||||
localStorage: { getItem: () => JSON.stringify({ silenceTimeout }) },
|
||||
...overrides,
|
||||
});
|
||||
const providers = vm.runInContext(`${source}\n[DeepgramProvider, ClaudeVoiceProvider, VoiceInput]`, context);
|
||||
return providers as Array<Record<string, any>>;
|
||||
}
|
||||
|
||||
describe('voice silence timeout', () => {
|
||||
for (const [index, name, legacy] of [
|
||||
[0, 'Deepgram', 3000],
|
||||
[1, 'Claude', 6000],
|
||||
[2, 'browser', 3000],
|
||||
] as const) {
|
||||
it(`${name} keeps its default and accepts longer thinking pauses`, () => {
|
||||
for (const [setting, delay] of [
|
||||
[undefined, legacy],
|
||||
[15, 15000],
|
||||
[30, 30000],
|
||||
] as const) {
|
||||
const provider = load(setting)[index];
|
||||
provider.isRecording = true;
|
||||
provider.stop = vi.fn();
|
||||
provider._resetSilenceTimeout();
|
||||
vi.advanceTimersByTime(delay - 1);
|
||||
expect(provider.stop).not.toHaveBeenCalled();
|
||||
vi.advanceTimersByTime(1);
|
||||
expect(provider.stop).toHaveBeenCalledOnce();
|
||||
}
|
||||
});
|
||||
it(`${name} resets its deadline and cancels it in manual mode`, () => {
|
||||
const providers = load(15);
|
||||
const provider = providers[index];
|
||||
provider.isRecording = true;
|
||||
provider.stop = vi.fn();
|
||||
provider._resetSilenceTimeout();
|
||||
vi.advanceTimersByTime(10000);
|
||||
provider._resetSilenceTimeout();
|
||||
vi.advanceTimersByTime(10000);
|
||||
expect(provider.stop).not.toHaveBeenCalled();
|
||||
providers[2]._getDeepgramConfig = () => ({ silenceTimeout: 0 });
|
||||
provider._resetSilenceTimeout();
|
||||
vi.advanceTimersByTime(120000);
|
||||
expect(provider.stop).not.toHaveBeenCalled();
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
});
|
||||
}
|
||||
it.each([null, -1, '15', 1.5, 999])('ignores invalid local values: %s', (setting) => {
|
||||
const voice = load(setting)[2];
|
||||
expect(voice.getSilenceTimeoutMs(6000)).toBe(6000);
|
||||
});
|
||||
it('does not stop interim dictation at the former 750ms stability deadline', () => {
|
||||
for (const setting of [0, 3, 15]) {
|
||||
const voice = load(setting)[2];
|
||||
voice.isRecording = true;
|
||||
voice._showPreview = vi.fn();
|
||||
voice.stop = vi.fn();
|
||||
voice._onWebSpeechResult({ results: [[{ transcript: 'thinking' }]] });
|
||||
vi.advanceTimersByTime(750);
|
||||
expect(voice.stop).not.toHaveBeenCalled();
|
||||
}
|
||||
});
|
||||
it('restarts browser recognition without extending the silence deadline', () => {
|
||||
const voice = load(15)[2];
|
||||
voice.isRecording = true;
|
||||
voice._recordingStartedAt = Date.now() - 1000;
|
||||
voice.recognition = { start: vi.fn() };
|
||||
voice.stop = vi.fn();
|
||||
voice._hasReceivedResult = true;
|
||||
voice._resetSilenceTimeout();
|
||||
vi.advanceTimersByTime(10000);
|
||||
voice._onWebSpeechEnd();
|
||||
expect(voice.recognition.start).toHaveBeenCalledOnce();
|
||||
vi.advanceTimersByTime(5000);
|
||||
expect(voice.stop).toHaveBeenCalledOnce();
|
||||
});
|
||||
it('keeps final browser phrases and Safari interim text until an explicit stop', () => {
|
||||
const voice = load(0)[2];
|
||||
voice.isRecording = true;
|
||||
voice._activeProvider = 'webspeech';
|
||||
for (const method of ['_showPreview', '_stopDurationTimer', '_stopLevelMeter', '_updateButtons', '_hidePreview'])
|
||||
voice[method] = vi.fn();
|
||||
voice._insertText = vi.fn();
|
||||
voice.recognition = { stop: vi.fn() };
|
||||
const result = [{ transcript: 'first phrase ' }];
|
||||
Object.assign(result, { isFinal: true });
|
||||
voice._onWebSpeechResult({ resultIndex: 0, results: [result] });
|
||||
expect(voice.isRecording).toBe(true);
|
||||
expect(voice._insertText).not.toHaveBeenCalled();
|
||||
const interim = [{ transcript: 'thinking aloud' }];
|
||||
voice._onWebSpeechResult({ resultIndex: 1, results: [result, interim] });
|
||||
voice.stop();
|
||||
voice.stop();
|
||||
expect(voice._insertText).not.toHaveBeenCalled();
|
||||
voice._onWebSpeechEnd();
|
||||
expect(voice._insertText).toHaveBeenCalledExactlyOnceWith('first phrase thinking aloud');
|
||||
});
|
||||
it('preserves mixed final/interim results across browser recognition restarts', () => {
|
||||
const voice = load(0)[2];
|
||||
voice.isRecording = true;
|
||||
voice._recordingStartedAt = Date.now() - 1000;
|
||||
voice._showPreview = vi.fn();
|
||||
voice.recognition = { start: vi.fn() };
|
||||
const final = Object.assign([{ transcript: 'first ' }], { isFinal: true });
|
||||
const interim = [{ transcript: 'second' }];
|
||||
voice._onWebSpeechResult({ resultIndex: 0, results: [final, interim] });
|
||||
expect(voice._accumulatedFinal + voice._pendingInterim).toBe('first second');
|
||||
voice._onWebSpeechEnd();
|
||||
voice._onWebSpeechResult({ resultIndex: 0, results: [[{ transcript: 'third' }]] });
|
||||
expect(voice._accumulatedFinal + voice._pendingInterim).toBe('first second third');
|
||||
});
|
||||
it('ignores repeated stop requests while waiting for Claude final text', () => {
|
||||
const [_, provider, voice] = load(0);
|
||||
voice.isRecording = true;
|
||||
voice._activeProvider = 'claude';
|
||||
provider._ws = { readyState: 1 };
|
||||
provider.stop = vi.fn(() => {
|
||||
provider._finalized = true;
|
||||
});
|
||||
voice._insertText = vi.fn();
|
||||
voice.stop();
|
||||
voice.stop();
|
||||
expect(voice._insertText).not.toHaveBeenCalled();
|
||||
expect(voice.isRecording).toBe(true);
|
||||
});
|
||||
it('keeps a corrected final through the configured silence deadline', () => {
|
||||
const voice = load(3)[2];
|
||||
voice.isRecording = true;
|
||||
voice._showPreview = vi.fn();
|
||||
voice.stop = vi.fn();
|
||||
voice._onWebSpeechResult({ resultIndex: 0, results: [[{ transcript: 'hello' }]] });
|
||||
const final = Object.assign([{ transcript: 'hello' }], { isFinal: true });
|
||||
voice._onWebSpeechResult({ resultIndex: 0, results: [final] });
|
||||
vi.advanceTimersByTime(750);
|
||||
expect(voice.stop).not.toHaveBeenCalled();
|
||||
expect(voice._accumulatedFinal + voice._pendingInterim).toBe('hello');
|
||||
});
|
||||
it('round-trips the setting through server validation', () => {
|
||||
for (const silenceTimeout of [0, 3, 6, 15, 30]) {
|
||||
expect(SettingsUpdateSchema.parse({ voiceSettings: { silenceTimeout } }).voiceSettings).toEqual({
|
||||
silenceTimeout,
|
||||
});
|
||||
}
|
||||
expect(SettingsUpdateSchema.safeParse({ voiceSettings: { silenceTimeout: -1 } }).success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
function claudeSession(setting: number) {
|
||||
const [, provider, voice] = load(setting);
|
||||
voice._claudeStatus = { available: true };
|
||||
for (const method of [
|
||||
'_showPreview',
|
||||
'_startDurationTimer',
|
||||
'_stopDurationTimer',
|
||||
'_stopLevelMeter',
|
||||
'_updateButtons',
|
||||
'_hidePreview',
|
||||
'_hideVoiceSendBtn',
|
||||
])
|
||||
voice[method] = vi.fn();
|
||||
voice._insertText = vi.fn();
|
||||
provider.start = vi.fn((opts) => {
|
||||
provider._finalized = false;
|
||||
provider._ws = { readyState: 1, send: vi.fn(), close: vi.fn() };
|
||||
provider._onResult = opts.onResult;
|
||||
provider._onEnd = opts.onEnd;
|
||||
provider._resetSilenceTimeout();
|
||||
});
|
||||
voice._startClaude();
|
||||
return { provider, voice };
|
||||
}
|
||||
|
||||
describe('Claude utterances and teardown', () => {
|
||||
it.each([0, 15])('keeps two final utterances through a thinking pause with setting %s', (setting) => {
|
||||
const { provider, voice } = claudeSession(setting);
|
||||
provider._onResult('first phrase', false);
|
||||
provider._onResult('first phrase', true);
|
||||
expect(voice.isRecording).toBe(true);
|
||||
vi.advanceTimersByTime(2000);
|
||||
provider._onResult('second phrase', false);
|
||||
voice.stop();
|
||||
provider._onResult('second phrase', true);
|
||||
expect(voice._insertText).toHaveBeenCalledExactlyOnceWith('first phrase second phrase');
|
||||
expect(voice.isRecording).toBe(false);
|
||||
});
|
||||
it('salvages partial text and leaves the mic idle on SSE cleanup', () => {
|
||||
const { provider, voice } = claudeSession(0);
|
||||
provider._onResult('keep this partial', false);
|
||||
voice.cleanup();
|
||||
expect(voice._insertText).toHaveBeenCalledExactlyOnceWith('keep this partial');
|
||||
expect(voice.isRecording).toBe(false);
|
||||
expect(voice._activeProvider).toBeNull();
|
||||
expect(provider._ws).toBeNull();
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
});
|
||||
it('bounds finalization triggered by the provider silence timer too', () => {
|
||||
const { provider, voice } = claudeSession(3);
|
||||
provider._onResult('before silence', false);
|
||||
vi.advanceTimersByTime(3000);
|
||||
expect(provider._finalized).toBe(true);
|
||||
expect(voice.isRecording).toBe(true);
|
||||
vi.advanceTimersByTime(5000);
|
||||
expect(voice._insertText).toHaveBeenCalledExactlyOnceWith('before silence');
|
||||
expect(voice.isRecording).toBe(false);
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
});
|
||||
it('bounds a dead-link finalization wait and repeated taps cannot extend it', () => {
|
||||
const { provider, voice } = claudeSession(0);
|
||||
provider._onResult('salvage on timeout', false);
|
||||
voice.stop();
|
||||
vi.advanceTimersByTime(4000);
|
||||
voice.stop();
|
||||
expect(voice.isRecording).toBe(true);
|
||||
vi.advanceTimersByTime(1000);
|
||||
expect(voice._insertText).toHaveBeenCalledExactlyOnceWith('salvage on timeout');
|
||||
expect(voice.isRecording).toBe(false);
|
||||
expect(provider._ws).toBeNull();
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('empty browser recognition runs', () => {
|
||||
it('backs off and stops after repeated instant empty ends instead of a tight restart loop', () => {
|
||||
const voice = load(0)[2];
|
||||
voice.isRecording = true;
|
||||
voice._activeProvider = 'webspeech';
|
||||
voice.recognition = { start: vi.fn() };
|
||||
voice.stop = vi.fn();
|
||||
voice._webSpeechRunStartedAt = Date.now();
|
||||
voice._onWebSpeechEnd();
|
||||
expect(voice.recognition.start).not.toHaveBeenCalled();
|
||||
vi.advanceTimersByTime(250);
|
||||
expect(voice.recognition.start).toHaveBeenCalledTimes(1);
|
||||
voice._onWebSpeechEnd();
|
||||
vi.advanceTimersByTime(250);
|
||||
expect(voice.recognition.start).toHaveBeenCalledTimes(2);
|
||||
voice._onWebSpeechEnd();
|
||||
vi.advanceTimersByTime(10000);
|
||||
expect(voice.recognition.start).toHaveBeenCalledTimes(2);
|
||||
expect(voice.stop).toHaveBeenCalledWith(true);
|
||||
});
|
||||
it('resets the empty-run budget after actual speech', () => {
|
||||
const voice = load(0)[2];
|
||||
voice.isRecording = true;
|
||||
voice._activeProvider = 'webspeech';
|
||||
voice.recognition = { start: vi.fn() };
|
||||
voice._showPreview = vi.fn();
|
||||
voice.stop = vi.fn();
|
||||
voice._webSpeechRunStartedAt = Date.now();
|
||||
voice._onWebSpeechEnd();
|
||||
vi.advanceTimersByTime(250);
|
||||
voice._onWebSpeechResult({ results: [[{ transcript: 'a real phrase' }]] });
|
||||
voice._onWebSpeechEnd();
|
||||
voice._onWebSpeechEnd();
|
||||
vi.advanceTimersByTime(250);
|
||||
voice._onWebSpeechEnd();
|
||||
vi.advanceTimersByTime(250);
|
||||
expect(voice.recognition.start).toHaveBeenCalledTimes(4);
|
||||
expect(voice.stop).not.toHaveBeenCalled();
|
||||
expect(voice._accumulatedFinal).toBe('a real phrase');
|
||||
});
|
||||
it('does not restart the microphone after Stop during an empty-run backoff', () => {
|
||||
const voice = load(0)[2];
|
||||
voice.isRecording = true;
|
||||
voice._activeProvider = 'webspeech';
|
||||
for (const method of ['_stopDurationTimer', '_stopLevelMeter', '_updateButtons', '_hidePreview'])
|
||||
voice[method] = vi.fn();
|
||||
voice.recognition = { start: vi.fn(), stop: vi.fn() };
|
||||
voice._onWebSpeechEnd();
|
||||
voice.stop();
|
||||
vi.advanceTimersByTime(10000);
|
||||
expect(voice.recognition.start).not.toHaveBeenCalled();
|
||||
expect(voice.isRecording).toBe(false);
|
||||
});
|
||||
|
||||
function speakThenFallSilent(setting: number) {
|
||||
const voice = load(setting)[2];
|
||||
for (const method of ['_showPreview', '_stopDurationTimer', '_stopLevelMeter', '_updateButtons', '_hidePreview'])
|
||||
voice[method] = vi.fn();
|
||||
voice._insertText = vi.fn();
|
||||
voice.recognition = { start: vi.fn(), stop: vi.fn(), abort: vi.fn() };
|
||||
voice.isRecording = true;
|
||||
voice._activeProvider = 'webspeech';
|
||||
voice._webSpeechRunStartedAt = Date.now();
|
||||
voice._resetSilenceTimeout();
|
||||
voice._onWebSpeechResult({ results: [Object.assign([{ transcript: 'one phrase' }], { isFinal: true })] });
|
||||
voice._onWebSpeechEnd();
|
||||
// Each silent run lasts the engine's whole no-speech window, then backs off.
|
||||
const silentRun = () => {
|
||||
vi.advanceTimersByTime(6000);
|
||||
voice._onWebSpeechEnd();
|
||||
vi.advanceTimersByTime(250);
|
||||
};
|
||||
return { voice, silentRun };
|
||||
}
|
||||
it.each([30, 0])('keeps listening through silent 6 s runs with setting %s', (setting) => {
|
||||
const { voice, silentRun } = speakThenFallSilent(setting);
|
||||
for (let run = 0; run < 4; run++) silentRun();
|
||||
expect(voice.isRecording).toBe(true);
|
||||
expect(voice._retryCount).toBe(0);
|
||||
expect(voice.recognition.start).toHaveBeenCalledTimes(5);
|
||||
expect(voice._insertText).not.toHaveBeenCalled();
|
||||
});
|
||||
it('ends 30 s dictation at its deadline, not at the third silent run', () => {
|
||||
const { voice, silentRun } = speakThenFallSilent(30);
|
||||
for (let run = 0; run < 4; run++) silentRun();
|
||||
// 25 s of silence so far; the deadline fires at 30 s and waits for the final.
|
||||
vi.advanceTimersByTime(30000 - 4 * 6250 - 1);
|
||||
expect(voice.isRecording).toBe(true);
|
||||
vi.advanceTimersByTime(1);
|
||||
voice._onWebSpeechEnd();
|
||||
expect(voice._insertText).toHaveBeenCalledExactlyOnceWith('one phrase');
|
||||
expect(voice.isRecording).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Claude acquisition cancellation', () => {
|
||||
function pendingMicrophone() {
|
||||
let grant!: (value: unknown) => void;
|
||||
const microphone = new Promise((resolve) => {
|
||||
grant = resolve;
|
||||
});
|
||||
const track = { stop: vi.fn() };
|
||||
const stream = { getTracks: () => [track] };
|
||||
const sockets: any[] = [];
|
||||
class Socket {
|
||||
static OPEN = 1;
|
||||
static CONNECTING = 0;
|
||||
readyState = 0;
|
||||
close = vi.fn();
|
||||
constructor() {
|
||||
sockets.push(this);
|
||||
}
|
||||
}
|
||||
const [, provider] = load(0, {
|
||||
navigator: { mediaDevices: { getUserMedia: () => microphone } },
|
||||
URLSearchParams,
|
||||
WebSocket: Socket,
|
||||
location: { protocol: 'https:', host: 'fixture.invalid' },
|
||||
window: {},
|
||||
});
|
||||
const onStream = vi.fn();
|
||||
const onError = vi.fn();
|
||||
return { provider, grant, track, stream, sockets, onStream, onError };
|
||||
}
|
||||
it('releases a microphone granted after cleanup without opening a socket', async () => {
|
||||
const h = pendingMicrophone();
|
||||
const started = h.provider.start({ onStream: h.onStream, onError: h.onError });
|
||||
h.provider._cleanup();
|
||||
h.grant(h.stream);
|
||||
await started;
|
||||
expect(h.track.stop).toHaveBeenCalledOnce();
|
||||
expect(h.sockets).toHaveLength(0);
|
||||
expect(h.onStream).not.toHaveBeenCalled();
|
||||
expect(h.onError).not.toHaveBeenCalled();
|
||||
expect(h.provider._stream).toBeNull();
|
||||
});
|
||||
it('cancels a connecting socket and detaches its capture callback', async () => {
|
||||
const h = pendingMicrophone();
|
||||
const started = h.provider.start({ onStream: h.onStream, onError: h.onError });
|
||||
h.grant(h.stream);
|
||||
await started;
|
||||
const socket = h.sockets[0];
|
||||
expect(socket.onopen).toBeTypeOf('function');
|
||||
h.provider._cleanup();
|
||||
expect(socket.onopen).toBeNull();
|
||||
expect(socket.close).toHaveBeenCalledOnce();
|
||||
expect(h.track.stop).toHaveBeenCalledOnce();
|
||||
expect(h.provider._ws).toBeNull();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user