mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-11 01:39:34 +01:00
Compare commits
45
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8bedbbe793 | ||
|
|
84229de575 | ||
|
|
4f2b7dfec9 | ||
|
|
eef02ab00a | ||
|
|
43e13e1b5d | ||
|
|
a032e9829a | ||
|
|
5cd8fa049a | ||
|
|
979275af1f | ||
|
|
ede99b309c | ||
|
|
c307d63d3b | ||
|
|
4c53d424a7 | ||
|
|
39f2e847d8 | ||
|
|
7a48778bf4 | ||
|
|
91a4aa6b4e | ||
|
|
3a3e2ee1a4 | ||
|
|
e06257c937 | ||
|
|
0e13d9b12f | ||
|
|
7f35f798ee | ||
|
|
308de1fd4d | ||
|
|
2d7cd46565 | ||
|
|
0cedeb2657 | ||
|
|
4f29b0187a | ||
|
|
687c75b490 | ||
|
|
9518f4ecec | ||
|
|
8d7d0c5c7f | ||
|
|
430bf6217b | ||
|
|
0bc5f0da14 | ||
|
|
8dc81d0089 | ||
|
|
5e80c0a1ca | ||
|
|
20cd2eccc9 | ||
|
|
2f9843c94a | ||
|
|
607c88afd9 | ||
|
|
bb088bcd3e | ||
|
|
79a6c03363 | ||
|
|
4808ee7085 | ||
|
|
ea27adf104 | ||
|
|
7d96b3dcf6 | ||
|
|
078bd74bd1 | ||
|
|
dfe1af2e5f | ||
|
|
572ff202bb | ||
|
|
ba6bff388a | ||
|
|
f8ff431d30 | ||
|
|
7602b6db21 | ||
|
|
a50d0db283 | ||
|
|
d62b11586b |
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.0",
|
||||
"version": "1.50.1",
|
||||
"author": {
|
||||
"name": "Ark0N",
|
||||
"url": "https://github.com/Ark0N"
|
||||
|
||||
@@ -1,5 +1,40 @@
|
||||
# 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.0 (must match `package.json`)
|
||||
**Version**: 1.50.1 (must match `package.json`)
|
||||
|
||||
## Project Overview
|
||||
|
||||
@@ -186,13 +186,13 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
||||
|
||||
**DeepSeek web UI** (`POST`/`GET`/`DELETE /api/deepseek/web`, `deepseek-web-server.ts`): the Run menu's "DeepSeek web UI..." entry supervises ONE background `dsh web` child process, deliberately **NOT a shell session**. ⚠️ Every piece is load-bearing: exactly one server (a second click REUSES it), restarted when the browser authority changes (`--trusted-host`, last asker wins), killed on shutdown via `stopDeepSeekWeb()` (the detached child would otherwise outlive Codeman and hold its port), and failures returned to the caller. ⚠️ Never hardcode the port: search from 3080 across 40, detect free ports by BINDING, then wait for the server to really answer. ⚠️ Both `POST` and `DELETE` must stay behind `canUsernameRunPrivilegedCommands` (booting a profile runs its plugin code; the server is shared). → [architecture-invariants#deepseek-web-ui](docs/architecture-invariants.md#deepseek-web-ui)
|
||||
|
||||
**Case rename** (`POST /api/cases/:name/rename`, `src/case-rename.ts`; `docs/wiki/Core-Concepts.md#case`): a case under the cases dir is its folder name and always moves; a linked case renames its alias and moves its real folder only with `renameFolder`; remote and Docker cases are refused. ⚠️ A folder that sessions have open is the hazard: live sessions at or under it make the route answer `409 requiresStop` with their names, and only `stopSessions: true` ends them (via `ctx.cleanupSession`: closed like a tab close, uploads included, nothing is restarted); the free-target pre-check runs before that and refuses a dangling symlink too (`lstat`, as `moveCaseFolder` does), so no session is ended for a move that cannot happen. `withCaseLock` holds the old and new paths for the whole operation (`isCasePathLocked`; the `Session` constructor throws for a working directory inside a held path, so nothing is constructed mid-move). ⚠️ A create awaits the owner's layout lock and a settings read between `new Session` and the server's map, where the rename looks for open sessions, so the constructor also records the session (`noteCaseSessionStarting`) until `registerSessionWithLayout()` or `stop()` settles it (30 s TTL for one that never registers), and a folder move refuses `409` with `renameCode: "BUSY"` while one is recorded at or under the folder (`isCaseSessionStarting`, checked in the same tick that takes the lock); an alias-only rename is not refused. A create whose path was validated before the move but whose `Session` is constructed after it is not covered (the plain check-then-use race of any create path), `moveCaseFolder` refuses an existing target itself (POSIX `rename` would silently replace an EMPTY directory) and retries `EBUSY`/`EPERM`/`EACCES` before answering `423 LOCKED`. If the registries (`linked-cases.json`, `caseOrder`, `lastUsedCase`) fail after the move, `settings.json` and the folder are put back (a failed move-back says the folder is now at the new path), and an error after `stopSessions` carries `stoppedSessions`. Claude Code keys history by path (`claudeProjectDirName`: every non-alphanumeric becomes `-`), so `<claude config>/projects/<old>` is renamed too, or merged entry by entry, never overwriting, into one already at the new key (case delete never removes history, so an earlier case of the new name leaves one); other CLIs' history, Docker bind mounts, `git worktree` links, cron jobs' `workingDir`, Claude history of subfolder sessions and Claude Code's per-project entry in `~/.claude.json` (local-scope `mcpServers`, `allowedTools`, trust; writing another CLI's config is the opt-in MCP sync's job) are NOT migrated. The history scanner prefers the decoded project key over a transcript's `cwd` that no longer exists, so Resume follows a rename. Only an admin's rename touches the server-wide `settings.json` (`caseOrder`, `lastUsedCase`); the owner's default session group is carried across with `TabLayoutService`. `settings.json` is written before `linked-cases.json`, so a failed or refused registry write puts `settings.json` back and leaves the registry as it was. ⚠️ Every write to `linked-cases.json` (link, unlink, rename, and a create or clone into a custom folder) goes through `mutateLinkedCases()` (`src/web/linked-cases-writer.ts`: serialized, temp file + rename, and a file that does not parse is refused, never read as `{}`; the one direct writer is config-backup restore, `restoreConfigBackup()`, which must work on such a file), and a check that must still hold when the entry is written (the name is free; for a rename, the old entry still names the folder) runs INSIDE its callback: two clones that claimed one name used to both pass a re-read made outside it and both answer success. Pinned by `test/linked-cases-writer.test.ts`, and for a rename by `test/routes/case-rename-routes.test.ts`.
|
||||
**Case rename** (`POST /api/cases/:name/rename`, `src/case-rename.ts`; `docs/wiki/Core-Concepts.md#case`): a case under the cases dir is its folder name and always moves; a linked case renames its alias and moves its real folder only with `renameFolder`; remote and Docker cases are refused. ⚠️ A folder that sessions have open is the hazard: live sessions at or under it make the route answer `409 requiresStop` with their names, and only `stopSessions: true` ends them (via `ctx.cleanupSession`: closed like a tab close, uploads included, nothing is restarted); the free-target pre-check runs before that and refuses a dangling symlink too (`lstat`, as `moveCaseFolder` does), so no session is ended for a move that cannot happen. `withCaseLock` holds the old and new paths for the whole operation (`isCasePathLocked`; the `Session` constructor throws for a working directory inside a held path, so nothing is constructed mid-move). ⚠️ A create awaits the owner's layout lock and a settings read between `new Session` and the server's map, where the rename looks for open sessions, so the constructor also records the session (`noteCaseSessionStarting`) until `registerSessionWithLayout()` or `stop()` settles it (30 s TTL for one that never registers), and a folder move refuses `409` with `renameCode: "BUSY"` while one is recorded at or under the folder (`isCaseSessionStarting`, checked in the same tick that takes the lock); an alias-only rename is not refused. A create whose path was validated before the move but whose `Session` is constructed after it is not covered (the plain check-then-use race of any create path), `moveCaseFolder` refuses an existing target itself (POSIX `rename` would silently replace an EMPTY directory) and retries `EBUSY`/`EPERM`/`EACCES` before answering `423 LOCKED`. If the registries (`linked-cases.json`, `caseOrder`, `lastUsedCase`, `lastPickedCase`, `caseSettings`) fail after the move, `settings.json` and the folder are put back (a failed move-back says the folder is now at the new path), and an error after `stopSessions` carries `stoppedSessions`. Claude Code keys history by path (`claudeProjectDirName`: every non-alphanumeric becomes `-`), so `<claude config>/projects/<old>` is renamed too, or merged entry by entry, never overwriting, into one already at the new key (case delete never removes history, so an earlier case of the new name leaves one); other CLIs' history, Docker bind mounts, `git worktree` links, cron jobs' `workingDir`, Claude history of subfolder sessions and Claude Code's per-project entry in `~/.claude.json` (local-scope `mcpServers`, `allowedTools`, trust; writing another CLI's config is the opt-in MCP sync's job) are NOT migrated. The history scanner prefers the decoded project key over a transcript's `cwd` that no longer exists, so Resume follows a rename. Only an admin's rename touches the server-wide `settings.json` (`caseOrder`, `lastUsedCase`, `lastPickedCase`, `caseSettings`); the owner's default session group is carried across with `TabLayoutService`. `settings.json` is written before `linked-cases.json`, so a failed or refused registry write puts `settings.json` back and leaves the registry as it was. ⚠️ Every write to `linked-cases.json` (link, unlink, rename, and a create or clone into a custom folder) goes through `mutateLinkedCases()` (`src/web/linked-cases-writer.ts`: serialized, temp file + rename, and a file that does not parse is refused, never read as `{}`; the one direct writer is config-backup restore, `restoreConfigBackup()`, which must work on such a file), and a check that must still hold when the entry is written (the name is free; for a rename, the old entry still names the folder) runs INSIDE its callback: two clones that claimed one name used to both pass a re-read made outside it and both answer success. Pinned by `test/linked-cases-writer.test.ts`, and for a rename by `test/routes/case-rename-routes.test.ts`.
|
||||
|
||||
**Custom Model Endpoint Profiles** (opt-in, `customModelEndpointsEnabled`, SYNCED, default OFF; `docs/custom-model-endpoints.md`): points a session at a user-configured OpenAI-compatible endpoint (store `custom-model-hosts.ts`, `~/.codeman/custom-model-hosts.json`, 0600). `authStyle` is `bearer` or `api-key`, never both headers (hangs the server). The per-CLI redirect is registry data, `capabilities.customModelInjection` (`env` / `configContentEnv` / `configDir` / `unsupported`), computed by the pure `custom-model-injection.ts`; ⚠️ `configDir` writes an isolated per-session config, NEVER the user's real `~/.codex`/`~/.pi`/`~/.omp`/grok config; the one exception is a recipe's `linkFromRealHome` (deepseek's `profiles`, `sessions`, `settings.yaml`), which symlinks those named entries back out of the real home so they survive the redirect. ⚠️ Claude applies via `Session.restartCli()` (a relaunch in the existing pane, so the conversation is pinned as `resumeSessionId`), the rest one-shot at launch; retired env keys must also be `setenv -u`'d (`_pendingEnvUnsets`), since tmux env survives `respawn-pane`. ⚠️ Remote/Docker sessions are refused (400). ⚠️ Persist only KEYS (`__customModel`), never values (they carry the API key); the one exception is `ownEnv`'s `homeEnvVar` entry, the session's OWN home path (absolute only: a `clis.json` override can name any variable there), which `setCustomModel()` puts back on clear and recovery clamps, since the selection overwrote the tmux `setenv` that held it. ⚠️ Every redirectable var must be in that CLI's `privilegedEnvKeys`, and `ANTHROPIC_*` stays out of claude's `allowedPrefixes`. ⚠️ The Run-menu picker builds entries from `window.__codemanCustomModelClis` (escaped via `escapeScriptJson()`), never a hardcoded CLI id list, and launches through `run()` via a temporary `_runMode` swap, never `setRunMode()`. ⚠️ Every write of `custom-model-hosts.json` goes through `mutateCustomModelHosts()` (`src/custom-model-hosts-writer.ts`: one at a time, temp file + rename, the route's check inside the callback, and a file that does not parse is refused with 409, never read as `[]` and replaced); the one direct writer is config-backup restore. The endpoint id `local-ollama` is reserved for the local model install (see Local model install). Pinned by `test/custom-model-hosts-writer.test.ts`. → [architecture-invariants#custom-model-endpoint-profiles](docs/architecture-invariants.md#custom-model-endpoint-profiles)
|
||||
|
||||
⚠️ **llama-swap endpoints** (one model at a time): the apply routes check `GET /running` and return `requiresConfirmation` before evicting a model another live session uses; `confirmedSwap` and `confirmedContext` are SEPARATE flags and must stay so. Claude alone gets a context floor (`CLAUDE_MIN_SAFE_CONTEXT_TOKENS`); context is parsed from `/running`'s `cmd`, never trusted from `/props`. The floor also divides that context by the model's parallel slot count (`modelSlotCounts`: `--parallel`/`-np` from `cmd`, else `/props` `total_slots`; `perSlotContext()`), and `/props` only ever RAISES that warning: it supplies the context only when `cmd` states none and the count only when `cmd` has no `--parallel`, never a larger pool. `/props` is probed once per launch command (it refreshes llama-swap's last-use time, so a `ttl` would never expire), and a `/props` context beside an explicit `--parallel N` is already per slot, so it is not divided again. Backend log lines come from llama-swap's `/api/events` `upstream` source, never `/logs`. → [architecture-invariants#custom-model-endpoint-profiles](docs/architecture-invariants.md#custom-model-endpoint-profiles)
|
||||
|
||||
**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. ⚠️ The answer lists EVERY model that fits, and the install accepts only a listed row: `MAX_ALTERNATIVES` is a bound above the catalogue's size (`test/local-model-catalog.test.ts` fails for a catalogue that outgrows it), never a way to shorten the list. ⚠️ `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 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
|
||||
|
||||
@@ -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: replace, never append. → [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 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)
|
||||
|
||||
**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,6 +243,8 @@ 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)
|
||||
@@ -265,7 +267,7 @@ The terminal provider scans `terminalLinkLine()`, which extends `terminalPathLin
|
||||
|
||||
**Web tabs** (dashboard URLs as tabs): a saved URL renders as a tab beside sessions, **NOT a `SessionMode`**, and is proxied through Codeman's own origin (`/webview/<cap>/...`). ⚠️ The proxy is NOT an API surface: its capability-based auth exemption stays fenced to safe methods and non-route paths (`test/webview-auth-exemption.test.ts`). ⚠️ Iframes omit `allow-same-origin` unless `trusted`, and `Authorization`/`codeman_session` are stripped upstream in both modes. ⚠️ **Egress guard**: link-local and cloud-metadata targets are refused at save time, by a sync hostname check at each connect (IP literals skip DNS), AND on the resolved address (`webview-egress.ts`); use the `undici` package's own `fetch` + `Agent`, never Node's global fetch. ⚠️ Loopback links in agent output auto-open as proxied web tabs (`openLinkThroughWebTabIfLoopback`), but never auto-route `*.localhost` (prompt-injectable DNS). Capabilities are revoked on logout (`revokeOwner`). → [architecture-invariants#web-tabs](docs/architecture-invariants.md#web-tabs), `docs/web-tabs.md`
|
||||
|
||||
**Multi-user mode** (opt-in `--multiuser` / `CODEMAN_MULTIUSER=1`, OFF by default): named users with scrypt-hashed passwords in `~/.codeman/users.json`. Gated everywhere by `isMultiUserMode()`; when OFF, behavior is byte-identical to single-user because every scoping helper short-circuits. ⚠️ **Not a security boundary at the agent layer**: every session still runs as the SAME OS account. This separates WORKSPACES; it does not sandbox users (Docker cases are the isolation story). Ownership threads through `Session.owner` and is enforced in `findSessionOrFail`, list endpoints, SSE routing (fail-closed), WS, search, and file-preview. ⚠️ `settings.json` is ONE server-wide file, so every write to it is admin-only (`PUT /api/settings`, `/api/execution/model-config`, `/api/cases/order`, plus `PUT /api/config`: 403 `FORBIDDEN` before parsing), a non-admin's quick-start/Ralph start skips `lastUsedCase`, its GET hides `voiceSettings.apiKey`/`caseOrder`/`lastUsedCase` from a non-admin (`settingsForNonAdmin`), and the browser skips those writes for a non-admin via `_settingsReadOnly()` (settings-ui.js; also true once a write came back 403 `FORBIDDEN`, since `/api/me` can be slow or fail; their run mode and respawn presets stay per-device, `_applyOwnSettings`) (`test/routes/system-routes-settings-admin-only.test.ts`). System ops are admin-only too (`/api/system/update`, `/api/system/span-displays`, `/api/tunnel/qr/regenerate`, `/api/cleanup-state`), and a non-admin's `/api/auth/revoke` revokes only their own logins (`test/routes/system-routes-system-ops-admin-only.test.ts`). → [architecture-invariants#multi-user-mode](docs/architecture-invariants.md#multi-user-mode), `docs/multi-user-plan.md`
|
||||
**Multi-user mode** (opt-in `--multiuser` / `CODEMAN_MULTIUSER=1`, OFF by default): named users with scrypt-hashed passwords in `~/.codeman/users.json`. Gated everywhere by `isMultiUserMode()`; when OFF, behavior is byte-identical to single-user because every scoping helper short-circuits. ⚠️ **Not a security boundary at the agent layer**: every session still runs as the SAME OS account. This separates WORKSPACES; it does not sandbox users (Docker cases are the isolation story). Ownership threads through `Session.owner` and is enforced in `findSessionOrFail`, list endpoints, SSE routing (fail-closed), WS, search, and file-preview. ⚠️ `settings.json` is ONE server-wide file, so every write to it is admin-only (`PUT /api/settings`, `/api/execution/model-config`, `/api/cases/order`, plus `PUT /api/config`: 403 `FORBIDDEN` before parsing), a non-admin's quick-start/Ralph start skips `lastUsedCase`, its GET hides `voiceSettings.apiKey`/`caseOrder`/`lastUsedCase`/`lastPickedCase`/`caseSettings` from a non-admin (`settingsForNonAdmin`), and the browser skips those writes for a non-admin via `_settingsReadOnly()` (settings-ui.js; also true once a write came back 403 `FORBIDDEN`, since `/api/me` can be slow or fail; their run mode and respawn presets stay per-device, `_applyOwnSettings`) (`test/routes/system-routes-settings-admin-only.test.ts`). System ops are admin-only too (`/api/system/update`, `/api/system/span-displays`, `/api/tunnel/qr/regenerate`, `/api/cleanup-state`), and a non-admin's `/api/auth/revoke` revokes only their own logins (`test/routes/system-routes-system-ops-admin-only.test.ts`). → [architecture-invariants#multi-user-mode](docs/architecture-invariants.md#multi-user-mode), `docs/multi-user-plan.md`
|
||||
|
||||
**Away digest**: `GET /api/away-digest` aggregates what happened while you were away from the lifecycle log, run-summary events, live sessions, token stats, and recent subagents. Pure aggregator in `web/away-digest.ts`. ⚠️ Returns `{success:true,digest}`, a legacy raw-ish shape consistent with the other raw GET handlers in `system-routes.ts`; frontend and tests read `.digest`. → [architecture-invariants#away-digest](docs/architecture-invariants.md#away-digest)
|
||||
|
||||
|
||||
@@ -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** 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.
|
||||
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.
|
||||
|
||||
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.0",
|
||||
"version": "1.50.1",
|
||||
"websiteUrl": "https://getcodeman.com",
|
||||
"repository": {
|
||||
"url": "https://github.com/Ark0N/Codeman",
|
||||
@@ -13,7 +13,7 @@
|
||||
{
|
||||
"registryType": "npm",
|
||||
"identifier": "aicodeman",
|
||||
"version": "1.50.0",
|
||||
"version": "1.50.1",
|
||||
"transport": {
|
||||
"type": "stdio"
|
||||
},
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
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',
|
||||
@@ -51,6 +52,7 @@ export const BROWSER_TEST_GLOBS = [
|
||||
'test/doctor-settings.browser.test.ts',
|
||||
'test/git-status.browser.test.ts',
|
||||
'test/edit-history.browser.test.ts',
|
||||
'test/sync-case-state.browser.test.ts',
|
||||
'test/split-pane-orchestration.browser.test.ts',
|
||||
'test/split-pane-auto-collapse.browser.test.ts',
|
||||
'test/spreadsheet-preview.browser.test.ts',
|
||||
@@ -62,6 +64,8 @@ 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',
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
+28
-8
@@ -901,6 +901,25 @@ 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
|
||||
@@ -1021,8 +1040,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` and
|
||||
`POST /api/v1/sessions/:id/html-view` accept an ABSOLUTE path inside it, while a relative path
|
||||
`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
|
||||
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:
|
||||
@@ -1595,12 +1614,12 @@ A local case whose folder is the top of a git repository can keep **worktrees**
|
||||
|
||||
## Settings
|
||||
|
||||
`~/.codeman/settings.json` is one file for the whole server: the synced App Settings (feature switches, the plan-usage collection switch `showPlanUsageLimits`, `runMode`, `respawnPresets`, `tunnelEnabled`, ...), plus `modelConfig`, `caseOrder` and `lastUsedCase`. Reads are open to every signed-in user; in multi-user mode a non-admin reads it without what is the admin's (`voiceSettings.apiKey`, the Deepgram key an admin saved, and the admin's case names in `caseOrder` and `lastUsedCase`). **In multi-user mode every write is admin only:** for a non-admin the routes below answer `403 FORBIDDEN` before the body is even validated, so nothing is written, no watcher is toggled and no tunnel is started. Single-user mode is unaffected. Every write to the file (these routes, and the `lastUsedCase` that `POST /api/quick-start` and `POST /api/ralph-loop/start` record before they answer) runs one at a time and replaces the file atomically (mode `0600`); the one write outside that queue is a config-backup restore, which must work on a file that does not parse. A write to a `settings.json` that exists but is not a JSON object is refused rather than starting from `{}`, which would erase every other setting: the routes below answer `409 CONFLICT` with a message naming the fix and leave the file untouched, the two starts skip their `lastUsedCase`, and an admin's `POST /api/cases/:name/rename` answers the same `409 CONFLICT`, checked before it ends any session or moves the folder. A missing or empty file still starts from `{}`.
|
||||
`~/.codeman/settings.json` is one file for the whole server: the synced App Settings (feature switches, the plan-usage collection switch `showPlanUsageLimits`, `runMode`, `respawnPresets`, `tunnelEnabled`, ...), plus `modelConfig`, `caseOrder`, `lastUsedCase`, `lastPickedCase` and `caseSettings`. Reads are open to every signed-in user; in multi-user mode a non-admin reads it without what is the admin's (`voiceSettings.apiKey`, the Deepgram key an admin saved, and the admin's case names in `caseOrder`, `lastUsedCase`, `lastPickedCase` and `caseSettings`). **In multi-user mode every write is admin only:** for a non-admin the routes below answer `403 FORBIDDEN` before the body is even validated, so nothing is written, no watcher is toggled and no tunnel is started. Single-user mode is unaffected. Every write to the file (these routes, and the `lastUsedCase` that `POST /api/quick-start` and `POST /api/ralph-loop/start` record before they answer) runs one at a time and replaces the file atomically (mode `0600`); the one write outside that queue is a config-backup restore, which must work on a file that does not parse. A write to a `settings.json` that exists but is not a JSON object is refused rather than starting from `{}`, which would erase every other setting: the routes below answer `409 CONFLICT` with a message naming the fix and leave the file untouched, the two starts skip their `lastUsedCase`, and an admin's `POST /api/cases/:name/rename` answers the same `409 CONFLICT`, checked before it ends any session or moves the folder. A missing or empty file still starts from `{}`.
|
||||
|
||||
| Method | Path | Body | Notes |
|
||||
| ------ | ----------------------------- | ------------------------------------ | ----- |
|
||||
| `GET` | `/api/settings` | none | The stored settings object. Never writes. For a non-admin in multi-user mode, without `voiceSettings.apiKey`, `caseOrder` and `lastUsedCase`. |
|
||||
| `PUT` | `/api/settings` | partial settings (strict schema) | Merged into the stored file; services (watchers, tunnel, tmux history limit) reconcile to the merged result. `403 FORBIDDEN` for a non-admin in multi-user mode. A tunnel enable with no login (no `CODEMAN_PASSWORD`, not multi-user) is refused separately (`403 OPERATION_FAILED`, resend with `acknowledgeUnauthTunnel: true`) unless the server runs with `CODEMAN_ALLOW_UNAUTHENTICATED_TUNNEL=1` or `--allow-unauthenticated-tunnel`. `CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK` does not count; when it is set, the refusal says so. An enable without that authorization is accepted only while the tunnel is already running and never stores `tunnelEnabled: true`; if the tunnel stops before the request would start it, it is not restarted and the answer is the same `403 OPERATION_FAILED` (the request's other settings are saved). |
|
||||
| `GET` | `/api/settings` | none | The stored settings object. Never writes. For a non-admin in multi-user mode, without `voiceSettings.apiKey`, `caseOrder`, `lastUsedCase`, `lastPickedCase` and `caseSettings`. |
|
||||
| `PUT` | `/api/settings` | partial settings (strict schema) | Merged into the stored file; services (watchers, tunnel, tmux history limit) reconcile to the merged result. `caseSettings` (per-case run options, `{ [case]: { agentTeams, opusContext1m } }`) is merged per case inside the serialised writer, so devices saving different cases never overwrite each other; it is ignored while `syncCaseState` is not `true` and answers `400` past 500 cases. `403 FORBIDDEN` for a non-admin in multi-user mode. A tunnel enable with no login (no `CODEMAN_PASSWORD`, not multi-user) is refused separately (`403 OPERATION_FAILED`, resend with `acknowledgeUnauthTunnel: true`) unless the server runs with `CODEMAN_ALLOW_UNAUTHENTICATED_TUNNEL=1` or `--allow-unauthenticated-tunnel`. `CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK` does not count; when it is set, the refusal says so. An enable without that authorization is accepted only while the tunnel is already running and never stores `tunnelEnabled: true`; if the tunnel stops before the request would start it, it is not restarted and the answer is the same `403 OPERATION_FAILED` (the request's other settings are saved). |
|
||||
| `GET` | `/api/tunnel/status` | none | `{ running, url, loginConfigured }`. `loginConfigured` is `true` when a login protects the server (`CODEMAN_PASSWORD` set or multi-user mode on), the same test the tunnel guard above applies; with `false` the browser asks before every tunnel enable. Neither opt-in variable makes it `true`. |
|
||||
| `GET` | `/api/execution/model-config` | none | `modelConfig` (cron's and the plan orchestrator's default model). |
|
||||
| `PUT` | `/api/execution/model-config` | `{ defaultModel?, agentTypeOverrides?, ... }` | Replaces `modelConfig`. `403 FORBIDDEN` for a non-admin in multi-user mode. |
|
||||
@@ -1609,7 +1628,7 @@ A local case whose folder is the top of a git repository can keep **worktrees**
|
||||
|
||||
A non-admin's `POST /api/quick-start` and `POST /api/ralph-loop/start` work as before but do not record their case as `lastUsedCase` (a server-wide key).
|
||||
|
||||
`POST /api/cases/:name/rename` (body `{ newName, renameFolder?, stopSessions? }`, strict) renames a case. It is not one of the admin-only writes above: in multi-user mode a non-admin renames their own cases, and only the `settings.json` part is skipped for them. A case under the cases directory is its folder, so it always moves; a linked case renames its alias in `linked-cases.json` and moves its real folder (next to the old one, named `newName`) only with `renameFolder: true`. Remote and Docker cases are `400`. The new name must be free in every namespace the list merges (`409`). Live sessions whose working directory is at or inside the folder that would move make it `409` with `requiresStop: true` and `sessions: [{ id, name }]`; retry with `stopSessions: true` to end them first (they are closed as by `DELETE /api/sessions/:id`, nothing is restarted); another user's session is `403`. The folder is held by an in-process lock for the whole operation (an overlapping rename is `409 BUSY`, and `new Session` refuses a working directory inside it); a session that is still being created in the folder (constructed, not yet registered, so it can be neither listed nor stopped) makes a folder move `409` with `renameCode: "BUSY"` too, and a retry once it is registered lists it like any open session; the move is retried for a transient `EBUSY`/`EPERM`/`EACCES` and then answers `423` with `renameCode: "LOCKED"`, a cross-device move is `400`, and if the registries cannot be written afterwards `settings.json` and the folder are put back (when the folder cannot be moved back, the answer is `500` and says it is now at the new path). A failed write is `500`. A linked case's entry is checked again where it is written, after any folder move, and a refusal there puts everything back the same way: a new name linked since the first check is `409 ALREADY_EXISTS` with `renameCode: "EXISTS"`, an old entry unlinked or changed since then is `409 CONFLICT` with `renameCode: "BUSY"`, and a `linked-cases.json` that stopped being readable or a JSON object is `409 CONFLICT` with no `renameCode` (one already damaged when the rename starts reads as empty, so a linked name answers `404`). An error from the rename itself carries `stoppedSessions`: how many sessions it ended before it failed. On success the response is `{ oldName, newName, oldPath, newPath, folderMoved, stoppedSessions }`; `caseOrder` and `lastUsedCase` (in multi-user mode, by an admin's rename only), `linked-cases.json` and Claude Code's `projects/<path>` history are updated (history already at the new path is merged into, never overwritten), and `case:renamed` is broadcast. A linked case is admin only in multi-user mode: for a non-admin the name is looked up in their own case space only (`404` when they have no case of that name).
|
||||
`POST /api/cases/:name/rename` (body `{ newName, renameFolder?, stopSessions? }`, strict) renames a case. It is not one of the admin-only writes above: in multi-user mode a non-admin renames their own cases, and only the `settings.json` part is skipped for them. A case under the cases directory is its folder, so it always moves; a linked case renames its alias in `linked-cases.json` and moves its real folder (next to the old one, named `newName`) only with `renameFolder: true`. Remote and Docker cases are `400`. The new name must be free in every namespace the list merges (`409`). Live sessions whose working directory is at or inside the folder that would move make it `409` with `requiresStop: true` and `sessions: [{ id, name }]`; retry with `stopSessions: true` to end them first (they are closed as by `DELETE /api/sessions/:id`, nothing is restarted); another user's session is `403`. The folder is held by an in-process lock for the whole operation (an overlapping rename is `409 BUSY`, and `new Session` refuses a working directory inside it); a session that is still being created in the folder (constructed, not yet registered, so it can be neither listed nor stopped) makes a folder move `409` with `renameCode: "BUSY"` too, and a retry once it is registered lists it like any open session; the move is retried for a transient `EBUSY`/`EPERM`/`EACCES` and then answers `423` with `renameCode: "LOCKED"`, a cross-device move is `400`, and if the registries cannot be written afterwards `settings.json` and the folder are put back (when the folder cannot be moved back, the answer is `500` and says it is now at the new path). A failed write is `500`. A linked case's entry is checked again where it is written, after any folder move, and a refusal there puts everything back the same way: a new name linked since the first check is `409 ALREADY_EXISTS` with `renameCode: "EXISTS"`, an old entry unlinked or changed since then is `409 CONFLICT` with `renameCode: "BUSY"`, and a `linked-cases.json` that stopped being readable or a JSON object is `409 CONFLICT` with no `renameCode` (one already damaged when the rename starts reads as empty, so a linked name answers `404`). An error from the rename itself carries `stoppedSessions`: how many sessions it ended before it failed. On success the response is `{ oldName, newName, oldPath, newPath, folderMoved, stoppedSessions }`; `caseOrder`, `lastUsedCase`, `lastPickedCase` and `caseSettings` (in multi-user mode, by an admin's rename only), `linked-cases.json` and Claude Code's `projects/<path>` history are updated (history already at the new path is merged into, never overwritten), and `case:renamed` is broadcast. A linked case is admin only in multi-user mode: for a non-admin the name is looked up in their own case space only (`404` when they have no case of that name).
|
||||
|
||||
### System operations
|
||||
|
||||
@@ -1766,7 +1785,7 @@ A probe that fails is an answer, not an error: a server that is not on macOS, or
|
||||
- `verdict`: `ok`, `limited` or `none`. `limited` is a statement about the Mac: no model with 12 billion parameters or more fits its memory, so what it can run handles small edits and questions and does not replace a cloud model. It is judged on every model that fits, before the free disk space is looked at and whatever the pick is. With `ok` the pick can still be a small model (a larger one may be slow, have no speed figure or have no room on the disk); `notes` then says so.
|
||||
- `reason`, present only with `none`: `not-macos`, `intel`, `memory`, `disk`, `macos` (too old) or `no-harness` (no agent CLI that can be connected to a local model is installed or installable). `memory` has three sentences: nothing fits at all (slot `memory`, the size the Mac is sold as), a model fits but only below the context length an agent CLI needs (slots `budget` and `need`, so a Mac that misses by a little reads as that), and the memory size could not be read (no slot).
|
||||
- `headline` and `notes[]`: `{ text, vars? }`. `text` is an English sentence that may hold `{placeholder}` slots, and `vars` carries the slot values as text. Notes, in order: the pick is a small model although larger ones fit (verdict `ok` only); a model that fits the memory was left out because its download would not leave 10 GiB free on the disk (slots `model` and `missing`: the best-ranked such model and the GB to free up); nobody tested the pick on a Mac like this one.
|
||||
- `pick` (absent with `none`) and `alternatives[]` (every other model of the catalogue that fits, best first; empty with `none`), each one model at one context length with one agent CLI. An install is accepted only for a row of this list. The order: a pair tested on a Mac like this one first, then speed (`ok` before `unknown` before `slow`), then the model's rank, the agent CLI's rank and the larger context. The memory fit decides whether a row is listed and what it says (`fit`, `tag`), it does not sort, so a model is offered at the largest context it fits at. A model whose download would leave less than 10 GiB free on the disk is not listed (an unknown free disk space excludes nothing):
|
||||
- `pick` (absent with `none`) and `alternatives[]` (at most four, best first; empty with `none`), each one model at one context length with one agent CLI. The order: a pair tested on a Mac like this one first, then speed (`ok` before `unknown` before `slow`), then the model's rank, the agent CLI's rank and the larger context. The memory fit decides whether a row is listed and what it says (`fit`, `tag`), it does not sort, so a model is offered at the largest context it fits at. A model whose download would leave less than 10 GiB free on the disk is not listed (an unknown free disk space excludes nothing):
|
||||
- `modelId` (`name:tag`), `modelLabel`, `license`, `paramsB` (parameters in billions), `context` (tokens);
|
||||
- `cli`, `cliLabel`, `cliInstalled` (`false`: the CLI is not on this machine yet but can be installed without administrator rights), `slowFirstTurn?`;
|
||||
- `needBytes` and `needGb` (memory the model needs at that context, at its PEAK: the weights, what stays held per token of context, what the runner holds beside both, and what it takes for a moment while a long prompt is processed), `needSource` (`measured`: the figures were measured for this model on a Mac; `estimated`: they are another model's), `downloadBytes` and `downloadGb`, `budgetShare` (need divided by `budgetBytes`, rounded to three decimals);
|
||||
@@ -1823,7 +1842,8 @@ 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
|
||||
WHOLE running transcript, not a delta), `{"t":"error","message"}` and
|
||||
current utterance so far, not a delta, and a `final: true` frame ends that
|
||||
utterance), `{"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**, 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 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:
|
||||
|
||||
- **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,6 +479,18 @@ 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.
|
||||
@@ -782,7 +794,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**.
|
||||
|
||||
⚠️ 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`
|
||||
⚠️ 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`
|
||||
|
||||
### Files panel search
|
||||
|
||||
@@ -872,7 +884,7 @@ Paths, agent descriptions and diff lines render via `textContent` only (browser
|
||||
|
||||
⚠️ **The tracked list is a fixed allowlist** (`CONFIG_BACKUP_FILES`), never "everything in the data dir", and restore writes only those names whatever a manifest lists. `state.json` is deliberately NOT tracked: it is rewritten on every session event, so it would make a snapshot per event and bury the real changes. It also holds cron jobs and respawn presets, which is the documented gap.
|
||||
|
||||
⚠️ **One snapshot per real change.** The change check compares a digest of the file set with the newest manifest's `hash`, looking through `VOLATILE_KEYS`, fields a tracked file rewrites on its own: `custom-model-hosts.json` `lastDiscoveredAt` (the discovery poll), `users.json` `lastLoginAt` (every Basic login), `settings.json` `lastUsedCase` (a session started in another case), `intents.json` `recentPrompts`/`updatedAt` (every captured prompt), `docker-cases.json` `lastClaudeSessionId` (a `/clear` in a Docker case) and `webviews.json` `lastOpenedAt` (opening a web tab). Each was found the same way: one tick-driven rewrite made a snapshot per tick, and the 20 kept copies covered about 95 minutes. A new self-rewritten field in a tracked file needs an entry there and a row in the `it.each` table of `test/config-backup.test.ts`. The snapshot still stores the bytes and restore verifies the raw digest; only the comparison looks through these keys (so Compare calls `intents.json` unchanged when only prompts differ, yet a restore drops the prompts captured since).
|
||||
⚠️ **One snapshot per real change.** The change check compares a digest of the file set with the newest manifest's `hash`, looking through `VOLATILE_KEYS`, fields a tracked file rewrites on its own: `custom-model-hosts.json` `lastDiscoveredAt` (the discovery poll), `users.json` `lastLoginAt` (every Basic login), `settings.json` `lastUsedCase` (a session started in another case) and `lastPickedCase` (a pick in a case picker), `intents.json` `recentPrompts`/`updatedAt` (every captured prompt), `docker-cases.json` `lastClaudeSessionId` (a `/clear` in a Docker case) and `webviews.json` `lastOpenedAt` (opening a web tab). Each was found the same way: one tick-driven rewrite made a snapshot per tick, and the 20 kept copies covered about 95 minutes. A new self-rewritten field in a tracked file needs an entry there and a row in the `it.each` table of `test/config-backup.test.ts`. The snapshot still stores the bytes and restore verifies the raw digest; only the comparison looks through these keys (so Compare calls `intents.json` unchanged when only prompts differ, yet a restore drops the prompts captured since).
|
||||
|
||||
⚠️ **Retention deletes only what this module made, and never the newest snapshot.** A folder counts only with the name pattern AND a parseable manifest of this origin, so a custom folder that also holds other things is safe to point at. A snapshot is aged from its SUCCESSOR's `createdAt` (its state was live until the next one was made): aging it by its own date deleted the last good copy on the first change after a quiet month, which is exactly the bad write the feature exists for. The keep count floor is 2 (`CONFIG_BACKUP_MIN_KEEP_COUNT`), since 1 deletes the state before every change. A new snapshot is never dated earlier than the newest one (`createdAt + 1`): one written while the clock read ahead (dual boot, a VM resumed with a stale clock) stayed newest after the correction, differed from every later pass, and made each one write a copy until real time caught up. The flip side, a clock once set to a future year, is a Troubleshooting entry (delete the future-dated folder).
|
||||
|
||||
|
||||
+22
-35
@@ -17,29 +17,28 @@ in [architecture-invariants.md](architecture-invariants.md#local-model-install).
|
||||
## Which model fits which Mac
|
||||
|
||||
The window offers the best model of the catalogue (`src/config/local-model-catalog.ts`) that
|
||||
fits this Mac's memory at a context a coding agent can work with, and under it every other
|
||||
model of the catalogue that fits (ten models today: a 16 GB Mac sees one of them, a 512 GB
|
||||
Mac all ten). What the catalogue answers today, before the runtime measured anything (the
|
||||
budget is then an estimate: 2/3 of the memory below 36 GB, 3/4 from 36 GB, less 1 GiB, and
|
||||
never more than the memory less 6, 8 or 12 GiB for macOS, Codeman, a browser and the agent):
|
||||
fits this Mac's memory at a context a coding agent can work with, and up to four more. What
|
||||
the catalogue answers today, before the runtime measured anything (the budget is then an
|
||||
estimate: 2/3 of the memory below 36 GB, 3/4 from 36 GB, less 1 GiB, and never more than the
|
||||
memory less 6, 8 or 12 GiB for macOS, Codeman, a browser and the agent):
|
||||
|
||||
| Memory | Budget | What it gets |
|
||||
| ---------------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 8 GB | 2.1 GB | Nothing. The smallest model a coding agent can work with needs 9.3 GB at 65,536 tokens |
|
||||
| 16 GB | 10.4 GB | A small model (`limited`): Ornith 1.0 9B at 65,536 tokens, 9.3 GB, tight (90%). Its build has no vision projector, which makes it 0.9 GB smaller than the other two 9B builds: MiMo V2.6 9B needs 10.2 GB there, 98.7% of the budget, just past the 98% a tight fit may reach. Once the runtime measures what a 16 GB Mac mini measured (10.7 GB), all three fit and MiMo is the pick. No 9B was run on such a Mac by us: only a run settles it |
|
||||
| 18 GB | 11.8 GB | A small model (`limited`): MiMo V2.6 9B at 131,072 tokens, 11.4 GB, tight. Ornith 1.0 9B (10.5 GB) and Qwen3.5 9B are the alternatives |
|
||||
| 24 GB | 16.1 GB | The same, with room. On a base M2 or M3 (100 GB/s) it computes to 9.3 tokens per second: slow |
|
||||
| 32 GB | 21.8 GB | No longer a small-model Mac: Muse Glimmer 30B fits at 65,536 tokens (21.2 GB, 97%, tight). It is a dense model. On an M1 Max or M2 Max it computes to 11.8 tokens per second and is the pick; on a base or Pro chip it computes to under 6 and is listed as slow, and the pick stays MiMo V2.6 9B at 131,072 tokens with the note that it is a small model. North Mini Code 1.0 needs 23.2 GB at 65,536 tokens; once the runtime measures about 3/4 of the memory, as it did on a 16 and a 48 GB Mac, the Mac gets it at 131,072 tokens, or Qwen3.8 27B at 65,536 tokens on a Max chip |
|
||||
| 36 GB | 27.9 GB | Qwen3.8 27B at 131,072 tokens on an M4 or M5 Max (27.0 GB, 97%); on a slower chip, where the dense 27B computes to under 10 tokens per second, Qwen3.6 35B-A3B as a GGUF build (27.1 GB, 97%). Both tight. Muse Glimmer 30B at 131,072 tokens (21.6 GB, 78%) is the alternative with room to spare |
|
||||
| 48 GB | 37.6 GB | Qwen3.6 35B-A3B (MLX) at 131,072 tokens: the pair we tested, "Recommended" on an Apple M5 Pro with 48 GB. On a Max chip Qwen3.8 27B at 262,144 tokens (32.1 GB, tight). The test was a short task: in a long session MLX may keep up to 8 GiB more, which brings this pair to the edge of the budget, and past it while a long prompt is processed |
|
||||
| 64 GB | 50.5 GB | Qwen3.8 27B at 262,144 tokens on a Max chip; Qwen3.6 35B-A3B (MLX) on a Pro chip |
|
||||
| 96 GB | 76.2 GB | Qwen3.8 27B at 262,144 tokens; on an M3 Max, Qwen3.6 35B-A3B (MLX) |
|
||||
| 128 GB | 102 GB | Ornith 1.5 122B-A10B (MLX) at 131,072 tokens, 97.4 GB, tight |
|
||||
| 192, 256, 512 GB | 154 GB+ | Ornith 1.5 122B-A10B at 262,144 tokens |
|
||||
| Memory | Budget | What it gets |
|
||||
| ---------------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 8 GB | 2.1 GB | Nothing. The smallest model a coding agent can work with needs 10.2 GB at 65,536 tokens |
|
||||
| 16 GB | 10.4 GB | Nothing. MiMo V2.6 9B at 65,536 tokens needs 10.2 GB, 98.7% of the budget, just past the 98% a tight fit may reach (it is 95% of the 10.7 GB a 16 GB Mac mini measured). On the edge either way: only a run on such a Mac settles it |
|
||||
| 18 GB | 11.8 GB | A small model (`limited`): MiMo V2.6 9B at 131,072 tokens, 11.4 GB, tight. Its base, Qwen3.5 9B, is the one alternative |
|
||||
| 24 GB | 16.1 GB | The same, with room. On a base M2 or M3 (100 GB/s) it computes to 9.3 tokens per second: slow |
|
||||
| 32 GB | 21.8 GB | The same. North Mini Code 1.0 needs 23.2 GB at 65,536 tokens; once the runtime measures about 3/4 of the memory, as it did on a 16 and a 48 GB Mac, the Mac gets it at 131,072 tokens, or Qwen3.8 27B at 65,536 tokens on a Max chip |
|
||||
| 36 GB | 27.9 GB | Qwen3.8 27B at 131,072 tokens on an M4 or M5 Max (27.0 GB, 97%); on a slower chip, where the dense 27B computes to under 10 tokens per second, Qwen3.6 35B-A3B as a GGUF build (27.1 GB, 97%). Both tight |
|
||||
| 48 GB | 37.6 GB | Qwen3.6 35B-A3B (MLX) at 131,072 tokens: the pair we tested, "Recommended" on an Apple M5 Pro with 48 GB. On a Max chip Qwen3.8 27B at 262,144 tokens (32.1 GB, tight). The test was a short task: in a long session MLX may keep up to 8 GiB more, which brings this pair to the edge of the budget, and past it while a long prompt is processed |
|
||||
| 64 GB | 50.5 GB | Qwen3.8 27B at 262,144 tokens on a Max chip; Qwen3.6 35B-A3B (MLX) on a Pro chip |
|
||||
| 96 GB | 76.2 GB | Qwen3.8 27B at 262,144 tokens; on an M3 Max, Qwen3.6 35B-A3B (MLX) |
|
||||
| 128 GB | 102 GB | Ornith 1.5 122B-A10B (MLX) at 131,072 tokens, 97.4 GB, tight |
|
||||
| 192, 256, 512 GB | 154 GB+ | Ornith 1.5 122B-A10B at 262,144 tokens |
|
||||
|
||||
"Tight" means the need is between 85% and 98% of the budget; above 98% a model is not
|
||||
offered. A model that computes to under 10 tokens per second on the chip is "slow" and ranks
|
||||
after the fast ones, which is why the chip changes the pick at 32 to 96 GB. Every model is
|
||||
after the fast ones, which is why the chip changes the pick at 36 to 96 GB. Every model is
|
||||
offered at the largest context it fits at, and never below 65,536 tokens: OpenCode's own
|
||||
prompt is 8,000 to 10,000 tokens, and at 32,768 it would compact after less than one default
|
||||
file read. `test/local-model-recommend.test.ts` ("every memory size") pins this table.
|
||||
@@ -78,10 +77,8 @@ Where every figure comes from:
|
||||
| Model | Runner | Memory figures | Speed (tokens per second per 100 GB/s) |
|
||||
| ---------------------------- | --------- | ----------------------------------------------------------------------------------- | --------------------------------------------------------- |
|
||||
| `qwen3.5:9b-q4_k_m` | llama.cpp | computed from the Qwen3.5-9B config | 9.3: a published run on an M1 Max |
|
||||
| `ornith:9b-q4_k_m` | llama.cpp | the same (a fine-tune of Qwen3.5-9B; its config and its GGUF header say so) | 9.3: the same architecture and weights size |
|
||||
| `mimo-v2.6:9b-q4_k_m` | llama.cpp | the same (a fine-tune of Qwen3.5-9B) | 9.3: the same architecture and size |
|
||||
| `north-mini-code-1.0:q4_k_m` | llama.cpp | computed from its config | 13.6: the 35B-A3B's, the same 3 billion active parameters |
|
||||
| `muse-glimmer:30b-q4_k_m` | llama.cpp | computed from its config: 13 full-attention and 39 sliding-window layers | 2.95: the dense 27B's, whose weights are the same size |
|
||||
| `qwen3.6:35b-a3b-mtp-q4_k_m` | llama.cpp | computed from the Qwen3.6-35B-A3B config, prediction layer included | 13.6: a published run on an M1 Max |
|
||||
| `qwen3.6:35b-a3b` | MLX | **measured** on an Apple M5 Pro with 48 GB | 16.9: a published run; 67.7 measured on the tested Mac |
|
||||
| `qwen3.8:27b-q8_0` | llama.cpp | computed from the Qwen3.8-27B config, prediction layer included | 1.7: the row below scaled by file size |
|
||||
@@ -89,16 +86,13 @@ Where every figure comes from:
|
||||
| `ornith-1.5:122b` | MLX | computed from the Qwen3.5-122B-A10B config, scaled by the measured MLX row, + 8 GiB | 5.0: the 35B-A3B's, x 3/10 active parameters |
|
||||
|
||||
Sizes are what the registry serves a Mac for each tag (every layer plus the config), read on
|
||||
2026-11-08 (the Ornith 1.0 and Muse Glimmer rows on 2026-11-10). Only one row was ever run on
|
||||
a Mac, so every other answer in the table above is a calculation. For a llama.cpp build the check after the download catches a load that was
|
||||
2026-11-08. Only one row was ever run on a Mac, so every other answer in the table above is a
|
||||
calculation. For a llama.cpp build the check after the download catches a load that was
|
||||
judged too kindly, but not what grows after it in host memory (the checkpoints and the prompt
|
||||
cache, which are counted, not checked); for an MLX build it catches neither (see "The check"
|
||||
below). The measured row's figures come from single long prompts, not a session of many
|
||||
requests, in which MLX may keep up to 8 GiB more. The catalogue header lists the models left
|
||||
out on purpose (no evidence of working tool calls, a restrictive licence, a known bug, a
|
||||
build that needs a newer runtime than the pinned one) and why. The strongest of those today
|
||||
is Ripple 65B Preview, published on 2026-11-09: every build of it requires Ollama 0.40.16,
|
||||
one above the pin.
|
||||
requests, in which MLX may keep up to 8 GiB more. The catalogue header lists the models left out on
|
||||
purpose (no evidence of working tool calls, a restrictive licence, a known bug) and why.
|
||||
|
||||
A new row needs its model's architecture (attention layers, K/V heads, head size, and its
|
||||
linear-attention or sliding-window layers, from its `config.json`) in the table of
|
||||
@@ -361,13 +355,6 @@ or `python3`.
|
||||
others are computed from their architecture, and the runtime settings they assume (an 8-bit
|
||||
K/V cache, flash attention, a 1 GiB prompt cache) were read in its source, not yet observed
|
||||
on a Mac. "Recommended" appears only for the pair we ran, on the Mac we ran it on.
|
||||
- **A tool call that quotes a tool call can be cut off.** The pinned runtime reads the tool
|
||||
calls of every Qwen-family model in the catalogue (the Ornith, MiMo and Qwen rows, the pair
|
||||
we ran included) with one parser, and that parser ends a call at the first closing tag it
|
||||
sees. An argument that itself contains a whole tool call, such as a prompt handed to a
|
||||
sub-agent that shows one, arrives cut off and the rest as text (runtime issue 16992, filed
|
||||
on `ornith:35b`; the fix, runtime pull request 17021, is open). The check after a download
|
||||
asks for one plain tool call and does not see this.
|
||||
- **The model stays in memory for ten minutes** after the last request, then the runtime
|
||||
unloads it.
|
||||
|
||||
|
||||
@@ -161,7 +161,7 @@ Codeman now ships a global **Startup Mode** picker (App Settings, Claude CLI tab
|
||||
| Screenshots `/api/screenshots` (deprecated) | Deprecated: removed in a later MAJOR, so no per-user subdir is planned; the replacement `POST /api/sessions/:id/paste-image` is already session-scoped. Former plan: per-user subdir `~/.codeman/screenshots/<username>/` in multi-user mode. Note: `GET /:name` deliberately rejects `/` in names as traversal, so derive the subdir server-side from `req.authUser` and keep client-visible names flat |
|
||||
| Attachments | Already session-scoped; inherits the session owner check. `attachmentConfineToWorkspace` is a global, default-OFF setting today: in multi-user mode it is FORCED ON for non-admins regardless of the setting (their attachments must resolve inside their own space); the setting keeps meaning what it means for admins |
|
||||
| File routes (browse/preview) | Path allowlist adds: non-admin paths must resolve (realpath) inside their own space or their own sessions' workingDirs |
|
||||
| Settings (`settings.json`) | Global, admin-only writes in multi-user mode; reads allowed (per-device display keys stay in localStorage as today). **Implemented (1.42.0):** `PUT /api/settings`, `PUT /api/execution/model-config`, `PUT /api/config` and `PUT /api/cases/order` answer 403 `FORBIDDEN` to a non-admin before parsing anything; quick-start and Ralph loop start skip a non-admin's `lastUsedCase`; the browser skips those writes for a non-admin (`_settingsReadOnly()`, settings-ui.js) and keeps their own run mode and respawn presets on the device; a non-admin's `GET /api/settings` omits `voiceSettings.apiKey`, `caseOrder` and `lastUsedCase` (`settingsForNonAdmin`). Per-user server settings: out of scope v1|
|
||||
| Settings (`settings.json`) | Global, admin-only writes in multi-user mode; reads allowed (per-device display keys stay in localStorage as today). **Implemented (1.42.0):** `PUT /api/settings`, `PUT /api/execution/model-config`, `PUT /api/config` and `PUT /api/cases/order` answer 403 `FORBIDDEN` to a non-admin before parsing anything; quick-start and Ralph loop start skip a non-admin's `lastUsedCase`; the browser skips those writes for a non-admin (`_settingsReadOnly()`, settings-ui.js) and keeps their own run mode and respawn presets on the device; a non-admin's `GET /api/settings` omits `voiceSettings.apiKey`, `caseOrder`, `lastUsedCase`, `lastPickedCase` and `caseSettings` (`settingsForNonAdmin`). Per-user server settings: out of scope v1|
|
||||
| System ops (self-update, tunnel toggle, span-displays, docker image build) | Admin-only. **Implemented (1.42.0):** `POST /api/system/update`, `/api/system/span-displays`, `/api/tunnel/qr/regenerate` and `/api/cleanup-state` answer 403 `FORBIDDEN` to a non-admin; a non-admin's `POST /api/auth/revoke` revokes only their own login sessions and `GET /api/tunnel/info` lists only those |
|
||||
| `getLightState` init snapshot | Filtered per connection. Actual contents to filter (verified): `sessions`, `scheduledRuns`, `respawnStatus`, `subagents`, `workflowRuns`, `planUsage` (host-plan telemetry: admin-only); `globalStats` stays coarse-global. Cron jobs are NOT in the snapshot (they have their own REST route; filter there). The snapshot is cached process-wide (`LIGHT_STATE_CACHE_TTL_MS`): either key the cache per role/user or filter AFTER the cache on each send |
|
||||
|
||||
|
||||
@@ -41,7 +41,7 @@ cron jobs.
|
||||
A field a file rewrites on its own is not a change, and the comparison looks through it (the
|
||||
snapshot still stores the file exactly as written, and a restore puts those fields back as they
|
||||
were): `custom-model-hosts.json` `lastDiscoveredAt` (the model-discovery poll, every five
|
||||
minutes), `users.json` `lastLoginAt`, `settings.json` `lastUsedCase`, `intents.json`
|
||||
minutes), `users.json` `lastLoginAt`, `settings.json` `lastUsedCase` and `lastPickedCase`, `intents.json`
|
||||
`recentPrompts` and `updatedAt` (every captured prompt) and `docker-cases.json`
|
||||
`lastClaudeSessionId` (a Docker case's conversation id after a `/clear`) and `webviews.json`
|
||||
`lastOpenedAt` (opening a web tab). Without that, every
|
||||
|
||||
@@ -78,6 +78,26 @@ 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,9 +248,19 @@ 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** 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.
|
||||
**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.
|
||||
|
||||
### First launch
|
||||
|
||||
|
||||
@@ -247,8 +247,9 @@ 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, the Deepgram API key, and the opt-in switch for transcribing through this
|
||||
server's Claude login, with its live credential status. See
|
||||
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
|
||||
[Input And Voice](Input-And-Voice).
|
||||
|
||||
### Shortcuts
|
||||
@@ -258,7 +259,7 @@ Rebinding for the shortcut registry. See [Keyboard Shortcuts](Keyboard-Shortcuts
|
||||
### System
|
||||
|
||||
`CLAUDE.md` template for new cases, default working directory, the image watcher, the prompt-cache keep-warm default (**Keep prompt caches warm**, off) and its cap (**Keep warm for at most**, 2 hours after a session's last prompt; see [Keeping Agents Running](Keeping-Agents-Running)), and
|
||||
Cloudflare tunnel controls including the tunnel URL. The **Diagnostics** group runs
|
||||
Cloudflare tunnel controls including the tunnel URL. **Sync cases across devices** (under Paths, off by default): Keeps each case's **Agent Teams** and **Opus 1M** options on the server (`caseSettings` in `settings.json`) and, when you return to a tab, switches it to the case you last chose on another device. Synced, off by default. Without it each browser keeps its own options for a case (the case list itself is always shared). The case names are stored in the shared settings file, so a non-admin in multi-user mode neither reads, writes nor follows them. Each save sends only that case and the server merges it, so devices saving different cases never overwrite each other; a case renamed in Codeman keeps its options, and deleting or unlinking it removes them. A save the server refuses leaves the choice on that device only until the next sync, which brings the server's copy back, and says so. Only a case picked in a case picker is followed, not one a quick-start or an agent's spawned worker used. If another device turns this off, this one stops following, and the server ignores case options sent while it is off. Turning it off leaves the stored options in `settings.json`, so turning it back on brings them back. A device that already has options for a case keeps them until a device changes that case's options while this is on. The **Diagnostics** group runs
|
||||
`codeman doctor` on the server and lists the agent CLIs, tmux, Node and the optional office
|
||||
tools with their versions and install hints (admin only in multi-user mode). In multi-user
|
||||
mode, the **Users** administration entry is injected here.
|
||||
|
||||
@@ -19,6 +19,13 @@ 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.0",
|
||||
"version": "1.50.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "aicodeman",
|
||||
"version": "1.50.0",
|
||||
"version": "1.50.1",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "aicodeman",
|
||||
"version": "1.50.0",
|
||||
"version": "1.50.1",
|
||||
"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,6 +50,7 @@ objc2-foundation = { version = "0.3.2", default-features = false, features = [
|
||||
"NSGeometry",
|
||||
"NSObject",
|
||||
"NSOperation",
|
||||
"NSProcessInfo",
|
||||
"NSRange",
|
||||
"NSSet",
|
||||
"NSString",
|
||||
|
||||
+140
-35
@@ -40,7 +40,8 @@ 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, 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. |
|
||||
| **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. |
|
||||
| **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)
|
||||
@@ -147,6 +148,30 @@ 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
|
||||
@@ -225,31 +250,93 @@ 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 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.
|
||||
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.
|
||||
|
||||
**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.
|
||||
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 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:
|
||||
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:
|
||||
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
|
||||
@@ -308,17 +395,35 @@ 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
|
||||
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.
|
||||
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".
|
||||
- **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
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
#!/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,6 +3,7 @@
|
||||
//! (`~/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;
|
||||
@@ -44,11 +45,16 @@ pub struct Config {
|
||||
pub auto_connect: bool,
|
||||
/// Always open in full screen (otherwise the last state is restored).
|
||||
pub start_fullscreen: bool,
|
||||
/// 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.
|
||||
/// 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.
|
||||
#[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")]
|
||||
@@ -64,6 +70,10 @@ 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 {
|
||||
@@ -71,6 +81,7 @@ impl Default for Config {
|
||||
auto_connect: true,
|
||||
start_fullscreen: false,
|
||||
logo_blink: BlinkStyles::default(),
|
||||
keep_awake: keep_awake::Settings::default(),
|
||||
window: None,
|
||||
}
|
||||
}
|
||||
@@ -415,9 +426,12 @@ mod tests {
|
||||
use crate::logo::BlinkStyle;
|
||||
let mut c = Config::default();
|
||||
assert_eq!(c.logo_blink, BlinkStyles::default());
|
||||
c.logo_blink.needs_you = BlinkStyle::Alarm;
|
||||
assert!(!c.logo_blink.enabled, "off until the user turns it on");
|
||||
c.logo_blink.enabled = true;
|
||||
c.logo_blink.needs_you = BlinkStyle::Wink;
|
||||
let text = serde_json::to_string(&c).unwrap();
|
||||
assert!(text.contains(r#""needsYou":"alarm""#), "{text}");
|
||||
assert!(text.contains(r#""needsYou":"wink""#), "{text}");
|
||||
assert!(text.contains(r#""enabled":true"#), "{text}");
|
||||
assert_eq!(serde_json::from_str::<Config>(&text).unwrap().logo_blink, c.logo_blink);
|
||||
for bad in [
|
||||
r#"{"logoBlink":3}"#,
|
||||
@@ -429,11 +443,39 @@ 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
+2680
-360
File diff suppressed because it is too large.
Load diff
@@ -222,6 +222,13 @@ 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};
|
||||
@@ -456,6 +463,17 @@ 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()?;
|
||||
|
||||
+284
-24
@@ -27,6 +27,7 @@ mod assets;
|
||||
mod config;
|
||||
mod credentials;
|
||||
mod gui_install;
|
||||
mod keep_awake;
|
||||
mod local_server;
|
||||
mod login;
|
||||
mod logo;
|
||||
@@ -110,6 +111,7 @@ 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.";
|
||||
@@ -182,6 +184,20 @@ 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 {
|
||||
@@ -318,6 +334,10 @@ 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() {
|
||||
@@ -370,6 +390,7 @@ 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,
|
||||
@@ -397,8 +418,19 @@ 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));
|
||||
}
|
||||
|
||||
@@ -703,6 +735,18 @@ 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,
|
||||
@@ -719,17 +763,20 @@ impl App {
|
||||
self.menu.help_menu.set_as_help_menu_for_nsapp();
|
||||
}
|
||||
}
|
||||
Event::WindowEvent { window_id, event, .. } => self.on_window_event(window_id, event, control_flow),
|
||||
Event::WindowEvent { window_id, event, .. } => self.on_window_event(window_id, event, target, 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.
|
||||
// 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.
|
||||
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();
|
||||
}
|
||||
@@ -790,17 +837,34 @@ impl App {
|
||||
self.fullscreen_retry = Some((now + FULLSCREEN_RETRY_EVERY, attempts + 1));
|
||||
}
|
||||
|
||||
fn on_window_event(&mut self, id: WindowId, event: WindowEvent, control_flow: &mut ControlFlow) {
|
||||
fn on_window_event(
|
||||
&mut self,
|
||||
id: WindowId,
|
||||
event: WindowEvent,
|
||||
target: &EventLoopWindowTarget<UserEvent>,
|
||||
control_flow: &mut ControlFlow,
|
||||
) {
|
||||
match event {
|
||||
WindowEvent::CloseRequested if id != self.main_id => self.remove_window(id),
|
||||
WindowEvent::CloseRequested => {
|
||||
if id == self.main_id {
|
||||
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 {
|
||||
// Closing the main window quits; the server and its
|
||||
// sessions keep running, so nothing is lost.
|
||||
self.record_main_frame();
|
||||
self.save_config();
|
||||
self.keep_awake.shutdown();
|
||||
*control_flow = ControlFlow::Exit;
|
||||
} else {
|
||||
self.remove_window(id);
|
||||
}
|
||||
}
|
||||
WindowEvent::Moved(_) | WindowEvent::Resized(_) if id == self.main_id => self.record_main_frame(),
|
||||
@@ -821,6 +885,7 @@ impl App {
|
||||
self.to_connect_page(msg);
|
||||
}
|
||||
}
|
||||
UserEvent::LogoLidStatus(line) => self.show_lid_status(line),
|
||||
UserEvent::ServerStartDone {
|
||||
generation,
|
||||
url,
|
||||
@@ -875,6 +940,7 @@ 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();
|
||||
@@ -937,6 +1003,14 @@ 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),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1030,35 +1104,214 @@ impl App {
|
||||
}
|
||||
}
|
||||
|
||||
/// The logo is watched while any style is chosen and a server is open.
|
||||
/// The logo is watched while the switch is on, a state has a style 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.any() && logo::available());
|
||||
let server = phase.loaded().filter(|_| styles.active() && 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) {
|
||||
// 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();
|
||||
let was_active = self.config.logo_blink.active();
|
||||
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 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"));
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
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) {
|
||||
@@ -1066,10 +1319,17 @@ 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,6 +4,7 @@ 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};
|
||||
|
||||
@@ -16,6 +17,7 @@ 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";
|
||||
@@ -26,9 +28,85 @@ pub struct AppMenu {
|
||||
pub window_menu: Submenu,
|
||||
pub help_menu: Submenu,
|
||||
pub start_fullscreen: CheckMenuItem,
|
||||
/// One item per state and style. Empty on a Mac without a lit logo, which has no
|
||||
/// The one switch for the logo. None 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> {
|
||||
@@ -67,6 +145,15 @@ 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",
|
||||
@@ -110,10 +197,20 @@ pub fn build_menu(config: &Config) -> muda::Result<AppMenu> {
|
||||
),
|
||||
],
|
||||
)?;
|
||||
// Only a Mac with a lit Apple logo has this menu at all.
|
||||
// Only a Mac with a lit Apple logo has these items at all: the switch, and the
|
||||
// style for each state behind it.
|
||||
let mut logo_styles = Vec::new();
|
||||
let mut logo_switch = None;
|
||||
let mut logo_lid = None;
|
||||
if logo::available() {
|
||||
let submenu = Submenu::new("Blink Apple Logo", true);
|
||||
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);
|
||||
for signal in Signal::ALL {
|
||||
let group = Submenu::new(signal.label(), true);
|
||||
for style in BlinkStyle::ALL {
|
||||
@@ -128,7 +225,12 @@ pub fn build_menu(config: &Config) -> muda::Result<AppMenu> {
|
||||
submenu.append(&group)?;
|
||||
}
|
||||
// Right under "Always Open in Full Screen", before the separator.
|
||||
view.insert(&submenu, 4)?;
|
||||
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);
|
||||
}
|
||||
let window_menu = Submenu::with_items(
|
||||
"Window",
|
||||
@@ -156,6 +258,9 @@ 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.0",
|
||||
"version": "1.50.1",
|
||||
"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'],
|
||||
'settings.json': ['lastUsedCase', 'lastPickedCase'],
|
||||
'intents.json': ['recentPrompts', 'updatedAt'],
|
||||
'docker-cases.json': ['lastClaudeSessionId'],
|
||||
'webviews.json': ['lastOpenedAt'],
|
||||
|
||||
@@ -24,9 +24,8 @@
|
||||
* throws on a bad row. test/local-model-catalog.test.ts pins that the stock constant parses,
|
||||
* so a bad row fails the gate and never reaches the route.
|
||||
*
|
||||
* Where each figure of the stock rows comes from, and what was checked (2026-11-08; the two rows
|
||||
* added on 2026-11-10, `ornith:9b-q4_k_m` and `muse-glimmer:30b-q4_k_m`, were read that day).
|
||||
* [V] read at the source that day, [M] measured on a Mac, [C] computed from [V] inputs:
|
||||
* Where each figure of the stock rows comes from, and what was checked (2026-11-08). [V] read
|
||||
* at the source that day, [M] measured on a Mac, [C] computed from [V] inputs:
|
||||
*
|
||||
* | Figure | Source | Checked |
|
||||
* |----------------------|-----------------------------------------------------------|--------------------------------------|
|
||||
@@ -75,10 +74,8 @@
|
||||
* src/local-model/runtime.ts, q8_0: 34 bytes per 32 values), and does not grow after that.
|
||||
* "MTP" below is a GGUF with a multi-token-prediction layer (`nextn_predict_layers` 1 in
|
||||
* its header, read on 2026-11-08 from the first bytes of each model layer: the two `-mtp`
|
||||
* tags and `qwen3.8:27b-q8_0` have it, the two 9B tags and North Mini Code do not; read
|
||||
* the same way on 2026-11-10, `ornith:9b-q4_k_m` and `muse-glimmer:30b-q4_k_m` do not
|
||||
* either). For such a model the runtime always passes
|
||||
* `--spec-type draft-mtp --spec-draft-n-max N`, N
|
||||
* tags and `qwen3.8:27b-q8_0` have it, the two 9B tags and North Mini Code do not). For
|
||||
* such a model the runtime always passes `--spec-type draft-mtp --spec-draft-n-max N`, N
|
||||
* being the tag's own `draft_num_predict` (its params layer: 4 for
|
||||
* `qwen3.8:27b-mtp-q4_k_m`, 2 for `qwen3.6:35b-a3b-mtp-q4_k_m`) or the runtime's default
|
||||
* of 4 (`qwen3.8:27b-q8_0` sets none).
|
||||
@@ -86,11 +83,9 @@
|
||||
* adds its prediction layer, whose cache llama-server keeps in a draft context of the
|
||||
* same length in the draft's own cache type, f16 by default (2 bytes per value).
|
||||
* - overhead = the fixed caches (the f32 recurrent state of the linear-attention layers,
|
||||
* or the sliding-window cache of `n_swa + 512` cells: `size_swa` in
|
||||
* src/llama-kv-cache-iswa.cpp, the cache every model with a sliding window gets), held
|
||||
* 1 + N times for an MTP model (llama.cpp keeps one recurrent state per drafted token
|
||||
* for rollback, `need_n_rs_seq()`), + the context checkpoints:
|
||||
* `LLAMA_ARG_CTX_CHECKPOINTS` host copies
|
||||
* or the sliding-window cache of `n_swa + 512` cells), held 1 + N times for an MTP
|
||||
* model (llama.cpp keeps one recurrent state per drafted token for rollback,
|
||||
* `need_n_rs_seq()`), + the context checkpoints: `LLAMA_ARG_CTX_CHECKPOINTS` host copies
|
||||
* (`RUNTIME_CTX_CHECKPOINTS`, 8) of the state that cannot be rolled back (the recurrent
|
||||
* state, or the sliding-window cache), + 1.0 GB for the runner's own buffers (calibrated
|
||||
* on a third-party table of a 27B model on llama.cpp: 8.4 GB at 4,096 tokens with
|
||||
@@ -130,59 +125,26 @@
|
||||
* checked. For an `mlx` row it proves only the weights (issue 19417): the figures above are
|
||||
* all that stands between such a row and swap.
|
||||
*
|
||||
* Models set aside on purpose (a weekly review should look at them again). Every reason was
|
||||
* read again at its source on 2026-11-10:
|
||||
* - `ripple-preview:65b` (Ricursive, Apache-2.0, a dense 65B published on 2026-11-09;
|
||||
* Terminal-Bench 2.1 80.5 and SWE-bench Verified 76.5 on its card; 38.8 GB as a GGUF build,
|
||||
* 45.8 GB as the MLX build a Mac pulls): every build requires runtime 0.40.16, one above
|
||||
* the pin. Computed from its config.json by the llamacpp rule, its GGUF build would be the
|
||||
* first row between the 27B and the 122B (47 GB at 131,072 tokens: 64 and 96 GB Macs, slow
|
||||
* below an Ultra chip). Look at it the day the pin moves.
|
||||
* Models set aside on purpose (a weekly review should look at them again):
|
||||
* - `bonsai-2:27b`, `bonsai-2:9b` (ternary builds of Qwen3.8-27B and Qwen3.5-9B; the 27B
|
||||
* would make a 24 GB Mac `ok`): the vendor reports no SWE-bench and no agent loop (its card
|
||||
* of 2026-11-04 still lists BFCL v3 only), and whether 2-bit weights keep tool calls
|
||||
* reliable was never measured.
|
||||
* would make a 24 GB Mac `ok`): the vendor reports no SWE-bench and no agent loop, and
|
||||
* whether 2-bit weights keep tool calls reliable was never measured.
|
||||
* - `ornith-1.5:9b`, `ornith-1.5:35b`, `ornith-1.5:397b` (GGUF only): strong cards, but their
|
||||
* manifests name no renderer and no tool parser, so whether tool calls work through the
|
||||
* runtime is unknown (runtime pull request 17965 is still open). `ornith-1.5:122b` has
|
||||
* both.
|
||||
* - `ornith:35b` (Ornith 1.0 35B-A3B, MIT, with a renderer and a tool parser; SWE-bench
|
||||
* Verified 75.6 against 73.4 for Qwen3.6 35B-A3B on Ornith's card): runtime issue 16992,
|
||||
* open, reports its tool calls arriving as text in long agent chains, and its size is
|
||||
* served by the pair we ran. Its 9B sibling `ornith:9b-q4_k_m` is listed: see that row
|
||||
* for why.
|
||||
* runtime is unknown (runtime pull request 17965 is open). `ornith-1.5:122b` has both.
|
||||
* - `gpt-oss:20b`: weak in agentic coding (SWE-bench Verified 52.4, Terminal-Bench 2.1 15.2
|
||||
* in NVIDIA's table).
|
||||
* - `flint:40b`: runtime issue 19191 (open) breaks integer tool arguments after the first
|
||||
* call.
|
||||
* - `flint:40b`: runtime issue 19191 breaks integer tool arguments after the first call.
|
||||
* - `lfm2.5:24b-a2b`: its licence allows commercial use only below USD 10M of revenue.
|
||||
* - `laguna-xs-2.1`: the runtime's own page names an open problem with it on macOS.
|
||||
* `laguna-s-2.1`: open runtime issues 17602 and 17632.
|
||||
* - `qwen3.8-flash-next:125b`: the Qwen Community License, not an open licence, and its
|
||||
* builds require runtime 0.40.16 since 2026-11-09.
|
||||
* - `nemotron-3.5-super:120b` (79.6 GB as the MLX build a Mac pulls): Terminal-Bench 2.1
|
||||
* 60.7 and SWE-bench Verified 71.1 in NVIDIA's table, below `ornith-1.5:122b` at a larger
|
||||
* download, so it adds no choice a 128 GB Mac would take.
|
||||
* - `qwen3.5:4b-q4_k_m` (3.3 GB, it would fit a 16 GB Mac with room): runtime issue 18916,
|
||||
* open, has it answer with thinking only in a long conversation at 65,536 tokens.
|
||||
* - `qwen3-coder-next` (51.7 GB): the generation before Qwen3.6. Its card shows its results
|
||||
* as pictures only, so there is no figure to rank it by against the rows listed here.
|
||||
* - `muse-glimmer:30b-mlx` and the `-dflash` tags of that model: open runtime issues 17656
|
||||
* and 18269 on the MLX build, and no rule here covers the memory of a separate draft
|
||||
* model. Its GGUF build `muse-glimmer:30b-q4_k_m` is listed.
|
||||
* - `qwen3.8-flash-next:125b`: the Qwen Community License, not an open licence.
|
||||
* - The plain tags `qwen3.5:9b`, `mimo-v2.6:9b` and `qwen3.8:27b`: a Mac pulls their MLX
|
||||
* builds, whose 8 GiB of prefix snapshots do not fit the Macs a 9B or a 27B is for. Their
|
||||
* GGUF builds are listed instead.
|
||||
* - `qwen3.5:122b-a10b`: replaced by `ornith-1.5:122b`, a fine-tune of it (SWE-bench
|
||||
* Verified 84.5 against 72.0) whose MLX build carries a renderer and a tool parser.
|
||||
*
|
||||
* One open issue touches rows that ARE listed: runtime issue 16992 (above). Its one diagnosed
|
||||
* cause sits in the tool parser itself (it closes a call at the first closing tag, so a tool
|
||||
* argument that quotes a whole tool call is cut off; the fix, runtime pull request 17021, is
|
||||
* open). The names `ornith` and `qwen3.5` map to that one parser in the pinned runtime
|
||||
* (model/parsers/parsers.go), so every Qwen-family row here shares it, the pair we ran
|
||||
* included. The check after a download asks for one plain tool call and cannot see this.
|
||||
*
|
||||
* `tested` has one entry: `qwen3.6:35b-a3b` with OpenCode on an Apple M5 Pro with 48 GB,
|
||||
* 2026-11-06, 67.7 tokens per second. Only a Mac of exactly that chip and memory size is told
|
||||
* "Recommended" and shown that speed in place of the estimate. Every other row was never run
|
||||
@@ -374,7 +336,7 @@ const MLX_PREFIX_CACHE_BYTES = 8 * 1024 * 1024 * 1024;
|
||||
*/
|
||||
export const STOCK_LOCAL_MODEL_CATALOG: LocalModelCatalog = {
|
||||
schema: 1,
|
||||
updated: '2026-11-10',
|
||||
updated: '2026-11-08',
|
||||
// The oldest macOS the model runtime supports.
|
||||
minMacosMajor: 14,
|
||||
// Ordered by rank. The higher rank wins among models that fit and whose speed is graded
|
||||
@@ -382,7 +344,7 @@ export const STOCK_LOCAL_MODEL_CATALOG: LocalModelCatalog = {
|
||||
// that are left out and why.
|
||||
models: [
|
||||
{
|
||||
// Small Macs (18 to 32 GB): the base model of the two rows below.
|
||||
// Small Macs (16 to 32 GB, `limited`): the base model of the row below.
|
||||
id: 'qwen3.5:9b-q4_k_m',
|
||||
label: 'Qwen3.5 9B',
|
||||
license: 'Apache-2.0',
|
||||
@@ -403,35 +365,7 @@ export const STOCK_LOCAL_MODEL_CATALOG: LocalModelCatalog = {
|
||||
tested: [],
|
||||
},
|
||||
{
|
||||
// The model a 16 GB Mac can hold: 9.3 GB at 65,536 tokens, tight. Ornith 1.0 9B, a
|
||||
// fine-tune of Qwen3.5-9B for coding agents, as a GGUF build without a vision projector,
|
||||
// so 0.9 GB smaller than the rows around it. SWE-bench Verified 69.4, SWE-bench Pro 42.9
|
||||
// and Terminal-Bench 2.1 43.1, against 53.2, 31.3 and 21.3 for its base (Ornith's card,
|
||||
// which puts that base 7 points under Xiaomi's figure for it: the two cards do not
|
||||
// compare). Ranked under MiMo for that, and because runtime issue 16992 was filed on its
|
||||
// 35B sibling, which has the same renderer and parser (see the header): it is the pick
|
||||
// only where nothing else fits. Text only.
|
||||
id: 'ornith:9b-q4_k_m',
|
||||
label: 'Ornith 1.0 9B',
|
||||
license: 'MIT',
|
||||
paramsB: 9,
|
||||
runner: 'llamacpp',
|
||||
bytes: 5_629_110_568,
|
||||
// The architecture of the row above (its config.json and its GGUF header say so), so
|
||||
// its figures.
|
||||
heldBytesPerToken: 17_408,
|
||||
overheadBytes: 52_690_944 * (1 + LLAMACPP_CTX_CHECKPOINTS) + LLAMACPP_RUNNER_BYTES + LLAMACPP_PROMPT_CACHE_BYTES,
|
||||
peakExtraBytes: 0,
|
||||
figures: 'estimated',
|
||||
contexts: [131072, 65536],
|
||||
rank: 2,
|
||||
// Not measured: the 9.3 of the row above, the same architecture and the same weights file
|
||||
// size (5.63 GB without the projector).
|
||||
speedPer100Gbps: 9.3,
|
||||
tested: [],
|
||||
},
|
||||
{
|
||||
// Small Macs (18 to 32 GB). MiMo-V2.6-Distill-Qwen-9B, a fine-tune of
|
||||
// Small Macs (16 to 32 GB, `limited`). MiMo-V2.6-Distill-Qwen-9B, a fine-tune of
|
||||
// Qwen3.5-9B for code and agents: SWE-bench Verified 61.1 and SWE-bench Pro 44.6,
|
||||
// against 60.0 and 32.0 for its base (Xiaomi's card). Requires exactly the pinned
|
||||
// runtime, 0.40.15.
|
||||
@@ -441,14 +375,14 @@ export const STOCK_LOCAL_MODEL_CATALOG: LocalModelCatalog = {
|
||||
paramsB: 9,
|
||||
runner: 'llamacpp',
|
||||
bytes: 6_550_811_033,
|
||||
// The architecture and the quantization of the base model's row, so its figures.
|
||||
// The architecture and the quantization of the row above, so its figures.
|
||||
heldBytesPerToken: 17_408,
|
||||
overheadBytes: 52_690_944 * (1 + LLAMACPP_CTX_CHECKPOINTS) + LLAMACPP_RUNNER_BYTES + LLAMACPP_PROMPT_CACHE_BYTES,
|
||||
peakExtraBytes: 0,
|
||||
figures: 'estimated',
|
||||
contexts: [131072, 65536],
|
||||
rank: 3,
|
||||
// Not measured: the 9.3 of the base model's row, the same architecture and file size.
|
||||
rank: 2,
|
||||
// Not measured: the 9.3 of the row above, the same architecture and file size.
|
||||
speedPer100Gbps: 9.3,
|
||||
tested: [],
|
||||
},
|
||||
@@ -470,39 +404,12 @@ export const STOCK_LOCAL_MODEL_CATALOG: LocalModelCatalog = {
|
||||
peakExtraBytes: 0,
|
||||
figures: 'estimated',
|
||||
contexts: [262144, 131072, 65536],
|
||||
rank: 4,
|
||||
rank: 3,
|
||||
// Not measured: the 13.6 measured for Qwen3.6 35B-A3B on llama.cpp, which has the same
|
||||
// 3 billion active parameters.
|
||||
speedPer100Gbps: 13.6,
|
||||
tested: [],
|
||||
},
|
||||
{
|
||||
// Meta's 30B for local agents, a dense 27.9B language model with a vision encoder:
|
||||
// SWE-bench Verified 76.0, SWE-bench Pro 51.2, Terminal-Bench 2.1 51.7 (Meta's card). Its
|
||||
// K/V cache is the smallest of the 27B class (2 K/V heads of 128, and three layers in
|
||||
// four look at a window of 2,048 tokens), so it is the first model of that class a 32 GB
|
||||
// Mac can hold: 21.2 GB at 65,536 tokens, tight. Dense, so it is slow below a Max chip.
|
||||
// Its own window ends at 131,072 tokens.
|
||||
id: 'muse-glimmer:30b-q4_k_m',
|
||||
label: 'Muse Glimmer 30B',
|
||||
license: 'Apache-2.0',
|
||||
paramsB: 29.6,
|
||||
runner: 'llamacpp',
|
||||
bytes: 18_157_010_252,
|
||||
// 13 full-attention layers x 2 K/V heads x 128 x 2 x 34/32.
|
||||
heldBytesPerToken: 7_072,
|
||||
// 39 sliding-window layers: (2,048 + 512) cells x 2 x 128 x 2 x 34/32 = 54,312,960, live
|
||||
// and in each context checkpoint.
|
||||
overheadBytes: 54_312_960 * (1 + LLAMACPP_CTX_CHECKPOINTS) + LLAMACPP_RUNNER_BYTES + LLAMACPP_PROMPT_CACHE_BYTES,
|
||||
peakExtraBytes: 0,
|
||||
figures: 'estimated',
|
||||
contexts: [131072, 65536],
|
||||
rank: 5,
|
||||
// Not measured: the 2.95 of Qwen3.8 27B, a dense model whose weights are the same size on
|
||||
// llama.cpp (16.8 GB each without the projector).
|
||||
speedPer100Gbps: 2.95,
|
||||
tested: [],
|
||||
},
|
||||
{
|
||||
// Macs of 36 GB on a slower chip: the model of the tested pair, as a GGUF build whose
|
||||
// memory does not grow after it loaded. With its multi-token-prediction layer.
|
||||
@@ -523,7 +430,7 @@ export const STOCK_LOCAL_MODEL_CATALOG: LocalModelCatalog = {
|
||||
peakExtraBytes: 0,
|
||||
figures: 'estimated',
|
||||
contexts: [262144, 131072, 65536],
|
||||
rank: 6,
|
||||
rank: 4,
|
||||
// Measured: 54.5 tokens per second on 400 GB/s (llama.cpp, without prediction).
|
||||
speedPer100Gbps: 13.6,
|
||||
tested: [],
|
||||
@@ -541,7 +448,7 @@ export const STOCK_LOCAL_MODEL_CATALOG: LocalModelCatalog = {
|
||||
...MEASURED_MEMORY,
|
||||
figures: 'measured',
|
||||
contexts: [131072, 65536],
|
||||
rank: 7,
|
||||
rank: 5,
|
||||
// Measured: 67.8 tokens per second on 400 GB/s with MLX. The one Mac we tested ran it at
|
||||
// 67.7 on 307 GB/s, so on other chips the estimate is on the low side.
|
||||
speedPer100Gbps: 16.9,
|
||||
@@ -566,7 +473,7 @@ export const STOCK_LOCAL_MODEL_CATALOG: LocalModelCatalog = {
|
||||
peakExtraBytes: 0,
|
||||
figures: 'estimated',
|
||||
contexts: [262144, 131072, 65536],
|
||||
rank: 8,
|
||||
rank: 6,
|
||||
// Not measured: the 2.95 of the row below scaled by file size.
|
||||
speedPer100Gbps: 1.7,
|
||||
tested: [],
|
||||
@@ -590,7 +497,7 @@ export const STOCK_LOCAL_MODEL_CATALOG: LocalModelCatalog = {
|
||||
peakExtraBytes: 0,
|
||||
figures: 'estimated',
|
||||
contexts: [262144, 131072, 65536],
|
||||
rank: 9,
|
||||
rank: 7,
|
||||
// Measured: 11.8 tokens per second on 400 GB/s (llama.cpp, Q4_K_M, without prediction).
|
||||
// This build predicts several tokens at a time, so it is faster than this figure says.
|
||||
speedPer100Gbps: 2.95,
|
||||
@@ -614,7 +521,7 @@ export const STOCK_LOCAL_MODEL_CATALOG: LocalModelCatalog = {
|
||||
peakExtraBytes: MEASURED_MEMORY.peakExtraBytes,
|
||||
figures: 'estimated',
|
||||
contexts: [262144, 131072],
|
||||
rank: 10,
|
||||
rank: 8,
|
||||
// Not measured: the 16.9 of Qwen3.6 35B-A3B on MLX x 3/10, its active parameters
|
||||
// against these, rounded down.
|
||||
speedPer100Gbps: 5,
|
||||
|
||||
@@ -28,10 +28,9 @@
|
||||
* gated, installed or installable, and accepting the model id.
|
||||
* - Ranking, lexicographic: tested on a Mac like this before not tested (a measurement beats
|
||||
* a formula); ok before unknown before slow; the higher model rank; the higher harness
|
||||
* rank; the larger context. The pick is the first candidate, the alternatives are EVERY
|
||||
* other model that fits (`MAX_ALTERNATIVES` is above what the catalogue holds), each at its
|
||||
* own best context and harness, so a model is always offered at the largest context it
|
||||
* fits at.
|
||||
* rank; the larger context. The pick is the first candidate, the alternatives are the next
|
||||
* four MODELS, each at its own best context and harness, so a model is always offered at
|
||||
* the largest context it fits at.
|
||||
* - Disk: a model whose download would leave less than 10 GiB free is not offered. It is not
|
||||
* dropped in silence: the best-ranked model left out that way is named in `noteDiskDropped`
|
||||
* with what is missing.
|
||||
@@ -110,13 +109,8 @@ export const SLOW_BELOW_TOKENS_PER_SECOND = 10;
|
||||
export const LIMITED_BELOW_PARAMS_B = 12;
|
||||
/** What must stay free on the disk after the model download. */
|
||||
export const DISK_HEADROOM_BYTES = 10 * GIB;
|
||||
/**
|
||||
* How many other models are listed under the pick, at most. Above what the stock catalogue
|
||||
* holds (10 models: 9 beside the pick), so a Mac is shown every model that fits it and the
|
||||
* install accepts every one of them. Still a bound: test/local-model-catalog.test.ts fails for
|
||||
* a catalogue that outgrows it, so nothing drops out of the list unseen.
|
||||
*/
|
||||
export const MAX_ALTERNATIVES = 11;
|
||||
/** How many other models are listed under the pick. */
|
||||
export const MAX_ALTERNATIVES = 4;
|
||||
|
||||
/** A memory size of at least this many GB (as sold) gets 3/4 of its memory as device limit, below it 2/3. */
|
||||
const LARGE_LIMIT_FROM_GB = 36;
|
||||
|
||||
@@ -115,6 +115,12 @@ 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';
|
||||
|
||||
/**
|
||||
|
||||
@@ -577,10 +577,10 @@ export interface Recommendation {
|
||||
/** Present exactly when `verdict` is `ok` or `limited`. */
|
||||
pick?: Candidate;
|
||||
/**
|
||||
* Every other model that fits and has room on the disk (`MAX_ALTERNATIVES` is above what the
|
||||
* catalogue holds), best first: tested before not tested, then ok before unknown before
|
||||
* slow speed, then the higher model rank. Each is at the largest context it fits at, with
|
||||
* the best harness that can run it there. Empty when `verdict` is `none`.
|
||||
* The next models that fit and have room on the disk (at most four), best first: tested
|
||||
* before not tested, then ok before unknown before slow speed, then the higher model rank.
|
||||
* Each is at the largest context it fits at, with the best harness that can run it there.
|
||||
* Empty when `verdict` is `none`.
|
||||
*/
|
||||
alternatives: Candidate[];
|
||||
/** `DeviceBudget.budgetBytes`. 0 when no budget is computed: not macOS, or a Mac without Apple silicon. */
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* @fileoverview Pure helpers for the opt-in shared per-case run options (`caseSettings` in
|
||||
* settings.json, written while `syncCaseState` is on). They run inside `mutateSettingsFile()`, so
|
||||
* every change is serialised with the file's other writers: two devices saving different cases
|
||||
* both land, where a replacement map would let the second discard the first.
|
||||
*/
|
||||
|
||||
export interface CaseRunOptions {
|
||||
agentTeams: boolean;
|
||||
opusContext1m: boolean;
|
||||
}
|
||||
|
||||
/** Most cases the shared map holds, counted after a merge (the schema bounds one request, not the file). */
|
||||
export const MAX_SHARED_CASES = 500;
|
||||
|
||||
export class SharedCaseLimitError extends Error {
|
||||
constructor() {
|
||||
super(`Shared case options are limited to ${MAX_SHARED_CASES} cases`);
|
||||
this.name = 'SharedCaseLimitError';
|
||||
}
|
||||
}
|
||||
|
||||
function asMap(value: unknown): Record<string, CaseRunOptions> {
|
||||
return value && typeof value === 'object' && !Array.isArray(value) ? (value as Record<string, CaseRunOptions>) : {};
|
||||
}
|
||||
|
||||
/** The stored map with `incoming` merged in per case. Throws SharedCaseLimitError past the limit. */
|
||||
export function mergeCaseSettings(current: unknown, incoming: Record<string, CaseRunOptions>) {
|
||||
const merged = { ...asMap(current), ...incoming };
|
||||
if (Object.keys(merged).length > MAX_SHARED_CASES) throw new SharedCaseLimitError();
|
||||
return merged;
|
||||
}
|
||||
|
||||
/** The map with `from` moved to `to`, or null when there is nothing to move. */
|
||||
export function renameCaseSettings(current: unknown, from: string, to: string) {
|
||||
const map = asMap(current);
|
||||
if (!Object.hasOwn(map, from)) return null;
|
||||
const { [from]: moved, ...rest } = map;
|
||||
return { ...rest, [to]: moved };
|
||||
}
|
||||
|
||||
/** The map without `name`, or null when it has no entry for it. */
|
||||
export function dropCaseSettings(current: unknown, name: string) {
|
||||
const map = asMap(current);
|
||||
if (!Object.hasOwn(map, name)) return null;
|
||||
const { [name]: _gone, ...rest } = map;
|
||||
return rest;
|
||||
}
|
||||
@@ -1232,6 +1232,11 @@ 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,20 +126,31 @@ Object.assign(CodemanApp.prototype, {
|
||||
this._stopHomeSessionsClock();
|
||||
},
|
||||
|
||||
/** Re-render only when showing (called from the tab renderer's tail). */
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
_refreshHomeSessionsIfVisible() {
|
||||
if (!this.isHomeSessionsVisible()) return;
|
||||
this._debouncedCall('homeSessions', () => this.renderHomeSessions(), 150);
|
||||
this._debouncedCall(
|
||||
'homeSessions',
|
||||
() => {
|
||||
if (this.isHomeSessionsVisible() && this.shouldShowHomeSessions()) this.renderHomeSessions();
|
||||
},
|
||||
150
|
||||
);
|
||||
},
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* One delegated click listener for every row. The width listener lives in
|
||||
* `_wireHomeSessionsBreakpoint()`, installed at startup.
|
||||
*/
|
||||
_wireHomeSessions(el) {
|
||||
if (this._homeSessionsWired) return;
|
||||
this._homeSessionsWired = true;
|
||||
this._wireHomeSessionsBreakpoint();
|
||||
|
||||
el.addEventListener('click', (event) => {
|
||||
const target = event.target?.closest?.('[data-hs-action]');
|
||||
@@ -150,20 +161,29 @@ Object.assign(CodemanApp.prototype, {
|
||||
void this.openWebview?.(target.dataset.hsWebview);
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
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);
|
||||
}
|
||||
/**
|
||||
* 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);
|
||||
},
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
@@ -1688,6 +1688,11 @@
|
||||
// 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.
|
||||
@@ -1734,6 +1739,11 @@
|
||||
(_m, what, why) =>
|
||||
`${what === 'Run mode' ? '运行模式' : '重生预设'}已保存到本设备,但未保存到服务器:${ZH_CN[why] || why}`,
|
||||
],
|
||||
// The case-options save (session-ui.js _pushCaseSettings): the case name passes through.
|
||||
[
|
||||
/^Options for "([^"]+)" \(until the next sync\) saved on this device, not on the server: ([\s\S]+)$/,
|
||||
(_m, name, why) => `案例“${name}”的选项已保存到本设备(同步前有效),但未保存到服务器:${ZH_CN[why] || why}`,
|
||||
],
|
||||
// A dialog read off a hook-less CLI's screen (src/web/screen-blocked.ts), as the
|
||||
// notification drawer shows it. The CLI's label passes through.
|
||||
[/^(.+) is waiting for your answer in the terminal$/, (_m, cli) => `${cli} 正在终端中等待你的回答`],
|
||||
|
||||
@@ -3456,6 +3456,20 @@
|
||||
<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>
|
||||
@@ -3572,6 +3586,13 @@
|
||||
</div>
|
||||
<input type="text" id="appSettingsDefaultDir" class="set-input" placeholder="~/projects">
|
||||
</div>
|
||||
<div class="set-row" data-search="sync cases across devices selected case agent teams opus 1m">
|
||||
<div class="set-row-text">
|
||||
<span class="set-row-label">Sync cases across devices</span>
|
||||
<span class="set-row-desc">Keeps each case's Agent Teams and Opus 1M options on the server and switches this device to the case you last chose on another one when you return to the tab. Off by default: each browser keeps its own. The case names are stored in the shared settings file.</span>
|
||||
</div>
|
||||
<label class="switch switch-sm"><input type="checkbox" id="appSettingsSyncCaseState" aria-label="Sync cases across devices"><span class="slider"></span></label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -74,6 +74,8 @@ const PathPicker = {
|
||||
_previewOverlay: null,
|
||||
_previewRequestSequence: 0,
|
||||
_previewPreviousFocus: null,
|
||||
_previewImageCleanup: null,
|
||||
_previewImageZoom: null,
|
||||
_showHidden: false,
|
||||
_sortMode: PATH_PICKER_DEFAULT_SORT,
|
||||
|
||||
@@ -563,6 +565,11 @@ 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>
|
||||
@@ -580,13 +587,30 @@ 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.addEventListener('load', () => body.querySelector('.path-preview-loading')?.remove());
|
||||
image.addEventListener('error', () => this.showPreviewError('Image preview failed to load'));
|
||||
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.src = previewUrl;
|
||||
body.appendChild(image);
|
||||
this._bindPreviewImageGestures(image, body, zoomControls);
|
||||
} else if (entry.previewKind === 'text') {
|
||||
fetch(previewUrl)
|
||||
.then(async (response) => {
|
||||
@@ -630,8 +654,142 @@ 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;
|
||||
|
||||
@@ -385,13 +385,13 @@ Object.assign(CodemanApp.prototype, {
|
||||
},
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* One delegated click listener for every row. The breakpoint listener lives
|
||||
* in `_wireMobileOverviewBreakpoint()`, installed at startup.
|
||||
*/
|
||||
_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,18 +426,34 @@ Object.assign(CodemanApp.prototype, {
|
||||
this.showWebviewModal();
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
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);
|
||||
}
|
||||
/**
|
||||
* 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);
|
||||
},
|
||||
|
||||
_toggleMobileOverviewRunMenu() {
|
||||
|
||||
@@ -3777,6 +3777,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
|
||||
const html = [];
|
||||
const canDelete = this._fileBrowserCanDelete(owner);
|
||||
|
||||
const renderNode = (node, depth) => {
|
||||
const isDir = node.type === 'directory';
|
||||
@@ -3799,6 +3800,13 @@ 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}">
|
||||
@@ -3807,6 +3815,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
<span class="${nameClass}">${escapeHtml(node.name)}</span>
|
||||
${sizeStr}
|
||||
${downloadBtn}
|
||||
${deleteBtn}
|
||||
</div>
|
||||
`);
|
||||
|
||||
@@ -3842,9 +3851,21 @@ 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);
|
||||
@@ -3970,6 +3991,7 @@ 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';
|
||||
@@ -3981,6 +4003,10 @@ 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>
|
||||
@@ -3988,6 +4014,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
<span class="${nameClass}">${escapeHtml(match.name)}</span>
|
||||
${sizeStr}
|
||||
${downloadBtn}
|
||||
${deleteBtn}
|
||||
</div>
|
||||
`;
|
||||
})
|
||||
@@ -4003,6 +4030,10 @@ 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');
|
||||
@@ -4012,6 +4043,33 @@ 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) {
|
||||
@@ -6105,6 +6163,7 @@ 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}`);
|
||||
@@ -6112,6 +6171,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
|
||||
switch (data.type) {
|
||||
case 'connected':
|
||||
connected = true;
|
||||
body.innerHTML = '';
|
||||
break;
|
||||
case 'data':
|
||||
@@ -6131,6 +6191,7 @@ 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,6 +613,9 @@ Object.assign(CodemanApp.prototype, {
|
||||
this.updateCasePickerInput(select.value);
|
||||
});
|
||||
select.dataset.listenerAdded = 'true';
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (document.visibilityState === 'visible') this._syncCaseStateFromServer();
|
||||
});
|
||||
}
|
||||
this.setupQuickStartCasePicker();
|
||||
// The phone overview labels rows with their case name, and a case rename or
|
||||
@@ -640,15 +643,29 @@ Object.assign(CodemanApp.prototype, {
|
||||
// Multi-user non-admin: lastUsedCase is a key of the server-wide settings.json, whose
|
||||
// PUT is admin-only (403), so it is not sent (_settingsReadOnly, settings-ui.js).
|
||||
if (this._settingsReadOnly?.()) return;
|
||||
try {
|
||||
await fetch('/api/settings', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ lastUsedCase: caseName })
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Failed to save last used case:', err);
|
||||
}
|
||||
// lastPickedCase is only what a person chose on a device (never a quick-start or an agent's spawn),
|
||||
// so it is what other devices follow when case sync is on.
|
||||
const sync = this._caseStateSyncOn?.() === true;
|
||||
const body = sync ? { lastUsedCase: caseName, lastPickedCase: caseName } : { lastUsedCase: caseName };
|
||||
const send = async () => {
|
||||
let stored = false;
|
||||
try {
|
||||
const res = await fetch('/api/settings', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
stored = res?.ok !== false;
|
||||
} catch (err) {
|
||||
console.error('Failed to save last used case:', err);
|
||||
}
|
||||
// While this device's own pick is not on the server, the next tab return must not follow the server's older one.
|
||||
if (sync) this._ownPickNotStored = !stored;
|
||||
};
|
||||
if (!sync) return send();
|
||||
// On the chain the tab-return refresh already awaits, so a pick lands before the copy it reads.
|
||||
this._caseSyncChain = (this._caseSyncChain || Promise.resolve()).then(send, send);
|
||||
return this._caseSyncChain;
|
||||
},
|
||||
|
||||
async quickStart() {
|
||||
@@ -3670,6 +3687,111 @@ Object.assign(CodemanApp.prototype, {
|
||||
|
||||
saveCaseSettings(caseName, settings) {
|
||||
localStorage.setItem('caseSettings_' + caseName, JSON.stringify(settings));
|
||||
if (this._caseStateSyncOn()) this._queueCaseSync(caseName, settings);
|
||||
},
|
||||
|
||||
// ── Opt-in case sync (`syncCaseState`): case options live on the server, the selected case follows other devices ──
|
||||
|
||||
/** Off unless the user turned it on; a non-admin in multi-user mode cannot write the shared settings.json. */
|
||||
_caseStateSyncOn() {
|
||||
return this.loadAppSettingsFromStorage?.()?.syncCaseState === true && !this._settingsReadOnly?.();
|
||||
},
|
||||
|
||||
/**
|
||||
* Send one case's options. Saves from this device run one after another in the order they were made
|
||||
* (one chain), and each carries only its own case: the server merges it into the shared map, so a save
|
||||
* from another device is never overwritten. `_caseSyncPending` counts a case's saves still in flight,
|
||||
* so an import of the server's copy leaves that case alone.
|
||||
*/
|
||||
_queueCaseSync(caseName, settings) {
|
||||
const entry = { agentTeams: !!settings.agentTeams, opusContext1m: !!settings.opusContext1m };
|
||||
const pending = this._caseSyncPending || (this._caseSyncPending = new Map());
|
||||
pending.set(caseName, (pending.get(caseName) || 0) + 1);
|
||||
const run = async () => {
|
||||
try {
|
||||
await this._pushCaseSettings(caseName, entry);
|
||||
} finally {
|
||||
const left = (pending.get(caseName) || 1) - 1;
|
||||
if (left > 0) pending.set(caseName, left);
|
||||
else pending.delete(caseName);
|
||||
}
|
||||
};
|
||||
this._caseSyncChain = (this._caseSyncChain || Promise.resolve()).then(run, run);
|
||||
return this._caseSyncChain;
|
||||
},
|
||||
|
||||
async _pushCaseSettings(caseName, entry) {
|
||||
let res = null;
|
||||
try {
|
||||
res = await fetch('/api/settings', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ caseSettings: { [caseName]: entry } }),
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Failed to sync case settings:', err);
|
||||
}
|
||||
// A refusal or failure keeps the choice on this device only until the next sync brings the server's copy back.
|
||||
await this._noteSettingsWriteResult?.(res, `Options for "${caseName}" (until the next sync)`);
|
||||
},
|
||||
|
||||
/** Server copy wins, except for a case this device is still saving: write its options into the local keys getCaseSettings() reads. */
|
||||
_applyServerCaseSettings(server) {
|
||||
if (server?.syncCaseState !== true || !server.caseSettings || typeof server.caseSettings !== 'object') return;
|
||||
// A non-admin never receives the map (the server redacts it) and must not apply one it was handed.
|
||||
if (this._settingsReadOnly?.()) return;
|
||||
try {
|
||||
let changed = false;
|
||||
for (const [name, s] of Object.entries(server.caseSettings)) {
|
||||
if (!s || typeof s !== 'object' || this._caseSyncPending?.has(name)) continue;
|
||||
const next = JSON.stringify({ agentTeams: s.agentTeams === true, opusContext1m: s.opusContext1m === true });
|
||||
if (localStorage.getItem('caseSettings_' + name) === next) continue;
|
||||
localStorage.setItem('caseSettings_' + name, next);
|
||||
changed = true;
|
||||
}
|
||||
// An open popover still shows the options it was filled with: closing it keeps a tick from writing them into another case.
|
||||
if (changed) this._closeCaseSettingsPopovers();
|
||||
} catch { /* storage blocked: this device keeps what it has */ }
|
||||
},
|
||||
|
||||
_closeCaseSettingsPopovers() {
|
||||
document.getElementById('caseSettingsPopover')?.classList.add('hidden');
|
||||
document.getElementById('caseSettingsPopoverMobile')?.classList.add('hidden');
|
||||
},
|
||||
|
||||
/** The server decides whether sync is on: when it says otherwise, this device's cached switch follows it. */
|
||||
_adoptServerCaseSyncPreference(server) {
|
||||
if (typeof server?.syncCaseState !== 'boolean') return;
|
||||
const cached = this.loadAppSettingsFromStorage();
|
||||
if ((cached.syncCaseState === true) === server.syncCaseState) return;
|
||||
this.saveAppSettingsToStorage?.({ ...cached, syncCaseState: server.syncCaseState });
|
||||
},
|
||||
|
||||
/** On returning to the tab: take the other devices' case options and selected case, if the server has sync on. */
|
||||
async _syncCaseStateFromServer() {
|
||||
if (this._settingsReadOnly?.()) return;
|
||||
// Off on this device: no request on every tab return. Turning it on elsewhere is learned from the
|
||||
// next settings load; turning it off elsewhere still reaches a device whose cached switch is on.
|
||||
if (this.loadAppSettingsFromStorage?.()?.syncCaseState !== true) return;
|
||||
const seq = (this._caseSyncRefresh = (this._caseSyncRefresh || 0) + 1);
|
||||
try {
|
||||
const chain = this._caseSyncChain;
|
||||
await chain; // this device's own saves land first, so the copy read next includes them
|
||||
const res = await fetch('/api/settings');
|
||||
if (!res.ok) return;
|
||||
const server = (await res.json())?.data ?? null;
|
||||
if (seq !== this._caseSyncRefresh || !server) return; // a newer refresh owns the answer
|
||||
if (this._caseSyncChain !== chain) return; // a save or pick made while the read was out is newer than it
|
||||
this._adoptServerCaseSyncPreference(server);
|
||||
if (server.syncCaseState !== true) return;
|
||||
this._applyServerCaseSettings(server);
|
||||
const select = document.getElementById('quickStartCase');
|
||||
const last = server.lastPickedCase;
|
||||
if (select && last && !this._ownPickNotStored && last !== select.value && this.cases?.some((c) => c.name === last)) {
|
||||
this._closeCaseSettingsPopovers();
|
||||
this.selectQuickStartCase(last, { save: false });
|
||||
}
|
||||
} catch { /* offline: try again next time the tab is shown */ }
|
||||
},
|
||||
|
||||
onCaseSettingChanged() {
|
||||
|
||||
@@ -560,6 +560,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
// .checked fires no onchange, so the list's visibility (and lazy load)
|
||||
// needs an explicit sync on every open, not just a save.
|
||||
this.applyCliManagementVisibility();
|
||||
document.getElementById('appSettingsSyncCaseState').checked = settings.syncCaseState === true;
|
||||
// MCP server sync: synced, default OFF; same explicit-sync reasoning as above.
|
||||
// The routes read the SAVED setting, so remember what it was on open: switching it on
|
||||
// here does nothing server-side until Save (see mcpSync()).
|
||||
@@ -810,6 +811,8 @@ 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');
|
||||
@@ -3139,6 +3142,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
customModelEndpointsEnabled: document.getElementById('appSettingsCustomModelEndpoints').checked,
|
||||
cliManagementEnabled: document.getElementById('appSettingsCliManagement').checked,
|
||||
mcpSyncEnabled: document.getElementById('appSettingsMcpSync').checked,
|
||||
syncCaseState: document.getElementById('appSettingsSyncCaseState').checked,
|
||||
configBackupEnabled: document.getElementById('appSettingsConfigBackup').checked,
|
||||
configBackupDir: document.getElementById('appSettingsConfigBackupDir').value.trim(),
|
||||
...this._configBackupRetentionPayload(_prev),
|
||||
@@ -3297,6 +3301,8 @@ 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);
|
||||
|
||||
@@ -5243,7 +5249,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
const settings = settingsPromise ? await settingsPromise : await fetch('/api/settings').then(r => r.ok ? r.json() : null).then(env => env?.success === true ? env.data : env);
|
||||
if (settings) {
|
||||
// Extract notification prefs before merging app settings
|
||||
const { notificationPreferences, voiceSettings, respawnPresets, runMode, ...appSettings } = settings;
|
||||
const { notificationPreferences, voiceSettings, respawnPresets, runMode, caseSettings: _sharedCases, ...appSettings } = settings;
|
||||
// Display settings are device-specific (mobile vs desktop) and must not be synced
|
||||
// from the server: see _displaySettingKeySet().
|
||||
const displayKeys = this._displaySettingKeySet();
|
||||
@@ -5280,6 +5286,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
// installs that had saved `false` (agent-skill-default-migration.ts).
|
||||
if (!('agentSkillEnabled' in appSettings)) delete merged.agentSkillEnabled;
|
||||
this.saveAppSettingsToStorage(merged);
|
||||
this._applyServerCaseSettings?.(settings);
|
||||
|
||||
// Apply notification prefs from server if present (only if localStorage has none)
|
||||
if (notificationPreferences && this.notificationManager) {
|
||||
|
||||
@@ -12386,6 +12386,32 @@ 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; }
|
||||
@@ -16218,6 +16244,29 @@ 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;
|
||||
@@ -16236,6 +16285,12 @@ 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;
|
||||
@@ -16250,10 +16305,23 @@ body.touch-device.cjk-input-visible .main {
|
||||
|
||||
.path-preview-image {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
margin: auto;
|
||||
object-fit: contain;
|
||||
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;
|
||||
}
|
||||
|
||||
.path-preview-frame {
|
||||
|
||||
+232
-137
@@ -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 3s silence. Shows floating preview overlay with recording
|
||||
* again to stop. Auto-stops after configurable 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 has auto-retry (up to 2x) for premature onend and iOS Safari stability check.
|
||||
* Web Speech API retries empty runs twice with backoff and preserves late results after Stop.
|
||||
*
|
||||
* @globals {object} ClaudeVoiceProvider
|
||||
* @globals {object} DeepgramProvider
|
||||
@@ -216,9 +216,12 @@ const DeepgramProvider = {
|
||||
|
||||
_resetSilenceTimeout() {
|
||||
clearTimeout(this._silenceTimeout);
|
||||
this._silenceTimeout = null;
|
||||
const delay = VoiceInput.getSilenceTimeoutMs(3000);
|
||||
if (delay === 0) return;
|
||||
this._silenceTimeout = setTimeout(() => {
|
||||
this.stop();
|
||||
}, 3000);
|
||||
}, delay);
|
||||
},
|
||||
|
||||
stop() {
|
||||
@@ -269,8 +272,8 @@ const DeepgramProvider = {
|
||||
* DeepgramProvider's recorder. The AudioContext is constructed at 16000 Hz so the
|
||||
* browser does the resampling.
|
||||
*
|
||||
* ⚠️ Transcript frames carry the WHOLE running transcript, not deltas. Callers
|
||||
* must replace, never concatenate.
|
||||
* Transcript frames cover the current utterance. Replace interims and append
|
||||
* each endpoint final once; an endpoint alone does not end the recording.
|
||||
*/
|
||||
const ClaudeVoiceProvider = {
|
||||
_ws: null,
|
||||
@@ -284,6 +287,8 @@ 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,
|
||||
@@ -293,6 +298,8 @@ 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;
|
||||
@@ -304,10 +311,17 @@ const ClaudeVoiceProvider = {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
this._stream = await navigator.mediaDevices.getUserMedia({
|
||||
const 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;
|
||||
@@ -331,10 +345,12 @@ 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().catch((err) => {
|
||||
this._startCapture(generation).catch((err) => {
|
||||
if (generation !== this._generation || this._finalized) return;
|
||||
this._onError?.('Microphone capture failed: ' + err.message);
|
||||
this.stop();
|
||||
});
|
||||
@@ -342,6 +358,7 @@ const ClaudeVoiceProvider = {
|
||||
};
|
||||
|
||||
this._ws.onmessage = (event) => {
|
||||
if (generation !== this._generation) return;
|
||||
let msg;
|
||||
try {
|
||||
msg = JSON.parse(event.data);
|
||||
@@ -361,6 +378,7 @@ 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) {
|
||||
@@ -385,16 +403,19 @@ const ClaudeVoiceProvider = {
|
||||
},
|
||||
|
||||
/** Wire mic -> 16 kHz PCM16 frames -> WebSocket. */
|
||||
async _startCapture() {
|
||||
async _startCapture(generation = this._generation) {
|
||||
if (generation !== this._generation || this._finalized) return;
|
||||
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);
|
||||
@@ -455,7 +476,10 @@ const ClaudeVoiceProvider = {
|
||||
|
||||
_resetSilenceTimeout() {
|
||||
clearTimeout(this._silenceTimeout);
|
||||
this._silenceTimeout = setTimeout(() => this.stop(), this.SILENCE_MS);
|
||||
this._silenceTimeout = null;
|
||||
const delay = VoiceInput.getSilenceTimeoutMs(this.SILENCE_MS);
|
||||
if (delay === 0) return;
|
||||
this._silenceTimeout = setTimeout(() => this.stop(), delay);
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -469,6 +493,13 @@ 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' }));
|
||||
@@ -510,15 +541,19 @@ 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) {
|
||||
if (this._ws.readyState === WebSocket.OPEN || this._ws.readyState === WebSocket.CONNECTING) {
|
||||
try { this._ws.close(1000); } catch (_e) { /* ignore */ }
|
||||
}
|
||||
this._ws = null;
|
||||
@@ -537,18 +572,27 @@ 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: '',
|
||||
_activeProvider: null, // 'deepgram' | 'webspeech' | null
|
||||
_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
|
||||
_recordingStartedAt: 0, // timestamp when recording started
|
||||
_retryCount: 0, // auto-retry counter for premature Web Speech API ends
|
||||
_hasReceivedResult: false, // whether any speech result came in this session
|
||||
_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
|
||||
_durationInterval: null, // timer for updating elapsed time display
|
||||
_analyser: null, // AudioContext analyser for level meter
|
||||
_analyserSource: null, // MediaStreamSource for level meter
|
||||
@@ -599,6 +643,12 @@ 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));
|
||||
},
|
||||
@@ -648,7 +698,18 @@ const VoiceInput = {
|
||||
if (!this.supported) return;
|
||||
|
||||
this.recognition = new SR();
|
||||
this.recognition.continuous = true;
|
||||
// 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.interimResults = true;
|
||||
this.recognition.lang = 'en-US';
|
||||
this.recognition.maxAlternatives = 1;
|
||||
@@ -656,6 +717,8 @@ 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() {
|
||||
@@ -706,7 +769,7 @@ const VoiceInput = {
|
||||
this.isRecording = true;
|
||||
this._activeProvider = 'claude';
|
||||
this._accumulatedFinal = '';
|
||||
this._lastTranscript = '';
|
||||
this._pendingInterim = '';
|
||||
this._hasReceivedResult = false;
|
||||
this._recordingStartedAt = Date.now();
|
||||
this._updateButtons('recording');
|
||||
@@ -725,25 +788,25 @@ const VoiceInput = {
|
||||
onResult: (text, isFinal) => {
|
||||
if (!this.isRecording) return;
|
||||
this._hasReceivedResult = true;
|
||||
// Each frame is the WHOLE running transcript, so replace rather than append.
|
||||
this._accumulatedFinal = text;
|
||||
// Interims replace the current utterance; each endpoint commits it once.
|
||||
if (isFinal) {
|
||||
this._hidePreview();
|
||||
this._insertText(text);
|
||||
this.stop();
|
||||
this._accumulatedFinal = [this._accumulatedFinal, text].filter(Boolean).join(' ');
|
||||
this._pendingInterim = '';
|
||||
if (ClaudeVoiceProvider._finalized) this.stop(true);
|
||||
else this._showPreview(this._accumulatedFinal, 'claude');
|
||||
} else {
|
||||
this._showPreview(text, 'claude');
|
||||
this._pendingInterim = (this._accumulatedFinal ? ' ' : '') + text;
|
||||
this._showPreview(this._accumulatedFinal + this._pendingInterim, 'claude');
|
||||
}
|
||||
},
|
||||
onError: (msg) => {
|
||||
const wasRecording = this.isRecording;
|
||||
this.stop();
|
||||
this.stop(true);
|
||||
if (wasRecording) app.showToast(msg, 'error');
|
||||
},
|
||||
onEnd: () => {
|
||||
if (this.isRecording) {
|
||||
if (this._accumulatedFinal) this._insertText(this._accumulatedFinal);
|
||||
this.stop();
|
||||
this.stop(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -756,7 +819,7 @@ const VoiceInput = {
|
||||
this.isRecording = true;
|
||||
this._activeProvider = 'deepgram';
|
||||
this._accumulatedFinal = '';
|
||||
this._lastTranscript = '';
|
||||
this._pendingInterim = '';
|
||||
this._hasReceivedResult = false;
|
||||
this._recordingStartedAt = Date.now();
|
||||
this._updateButtons('recording');
|
||||
@@ -777,12 +840,12 @@ const VoiceInput = {
|
||||
if (!this.isRecording) return;
|
||||
this._hasReceivedResult = true;
|
||||
if (isFinal) {
|
||||
this._accumulatedFinal += text;
|
||||
this._hidePreview();
|
||||
this._insertText(this._accumulatedFinal);
|
||||
this.stop();
|
||||
this._accumulatedFinal += (this._accumulatedFinal ? ' ' : '') + text;
|
||||
this._pendingInterim = '';
|
||||
this._showPreview(this._accumulatedFinal, 'deepgram');
|
||||
} else {
|
||||
const display = this._accumulatedFinal + text;
|
||||
this._pendingInterim = (this._accumulatedFinal ? ' ' : '') + text;
|
||||
const display = this._accumulatedFinal + this._pendingInterim;
|
||||
this._showPreview(display, 'deepgram');
|
||||
}
|
||||
},
|
||||
@@ -793,9 +856,6 @@ const VoiceInput = {
|
||||
},
|
||||
onEnd: () => {
|
||||
if (this.isRecording) {
|
||||
if (this._accumulatedFinal) {
|
||||
this._insertText(this._accumulatedFinal);
|
||||
}
|
||||
this.stop();
|
||||
}
|
||||
}
|
||||
@@ -818,44 +878,74 @@ const VoiceInput = {
|
||||
}
|
||||
this.isRecording = true;
|
||||
this._activeProvider = 'webspeech';
|
||||
this._retryCount = 0;
|
||||
this._webSpeechCommitted = '';
|
||||
this._webSpeechSoundActive = false;
|
||||
this._webSpeechActivityUntil = 0;
|
||||
this._accumulatedFinal = '';
|
||||
this._lastTranscript = '';
|
||||
this._pendingInterim = '';
|
||||
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();
|
||||
this.stop(true);
|
||||
app.showToast('Voice input failed to start: ' + e.message, 'error');
|
||||
return;
|
||||
}
|
||||
}
|
||||
this._resetSilenceTimeout();
|
||||
// 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 */ });
|
||||
// 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();
|
||||
// Haptic feedback on mobile
|
||||
if (navigator.vibrate) navigator.vibrate(50);
|
||||
},
|
||||
|
||||
stop() {
|
||||
stop(providerFinished = false) {
|
||||
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();
|
||||
@@ -863,21 +953,10 @@ const VoiceInput = {
|
||||
this._hidePreview();
|
||||
|
||||
if (this._activeProvider === 'claude') {
|
||||
// Finalize, don't hang up: the last transcript arrives after the audio does.
|
||||
ClaudeVoiceProvider.stop();
|
||||
// Completion received, or forced teardown: callbacks must not outlive it.
|
||||
ClaudeVoiceProvider._cleanup();
|
||||
} 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;
|
||||
|
||||
@@ -885,41 +964,44 @@ 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();
|
||||
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);
|
||||
// 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);
|
||||
}
|
||||
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();
|
||||
this.stop(true);
|
||||
if (!wasRecording) return;
|
||||
|
||||
switch (event.error) {
|
||||
@@ -943,27 +1025,45 @@ 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;
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
// 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();
|
||||
return;
|
||||
}
|
||||
|
||||
// Genuine end — finalize any accumulated text
|
||||
if (this._accumulatedFinal) {
|
||||
this._insertText(this._accumulatedFinal);
|
||||
}
|
||||
this.stop();
|
||||
this.stop(true);
|
||||
},
|
||||
|
||||
/** The session this dictation belongs to (see _targetSessionId). */
|
||||
@@ -1138,30 +1238,14 @@ 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();
|
||||
}
|
||||
}, 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);
|
||||
}
|
||||
}, delay);
|
||||
},
|
||||
|
||||
_startDurationTimer() {
|
||||
@@ -1200,6 +1284,8 @@ const VoiceInput = {
|
||||
},
|
||||
|
||||
_stopLevelMeter() {
|
||||
this._webSpeechSoundActive = false;
|
||||
this._webSpeechActivityUntil = 0;
|
||||
if (this._levelAnimFrame) {
|
||||
cancelAnimationFrame(this._levelAnimFrame);
|
||||
this._levelAnimFrame = null;
|
||||
@@ -1216,13 +1302,21 @@ const VoiceInput = {
|
||||
},
|
||||
|
||||
_drawLevelMeter() {
|
||||
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;
|
||||
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;
|
||||
}
|
||||
// Update the level bars in the preview
|
||||
const barsEl = this.previewEl?.querySelector('.voice-level-bars');
|
||||
if (barsEl) {
|
||||
@@ -1252,6 +1346,8 @@ 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';
|
||||
@@ -1319,28 +1415,27 @@ const VoiceInput = {
|
||||
|
||||
/** Cleanup on SSE reconnect or page unload */
|
||||
cleanup() {
|
||||
if (this.isRecording) this.stop();
|
||||
// Hide the Send button first: stop(true) below may insert the words it
|
||||
// salvaged and show the button again for them.
|
||||
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._lastTranscript = '';
|
||||
this._pendingInterim = '';
|
||||
this._retryCount = 0;
|
||||
this._hasReceivedResult = false;
|
||||
}
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
{
|
||||
"version": "1.50.0",
|
||||
"version": "1.50.1",
|
||||
"highlights": [
|
||||
"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)"
|
||||
"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)"
|
||||
]
|
||||
}
|
||||
@@ -87,6 +87,7 @@ import {
|
||||
settingsWriteErrorResponse,
|
||||
} from '../settings-writer.js';
|
||||
import { LinkedCasesWriteRefusedError, linkedCasesPath, mutateLinkedCases } from '../linked-cases-writer.js';
|
||||
import { dropCaseSettings, renameCaseSettings } from '../case-state-sync.js';
|
||||
import { isMultiUserMode } from '../../config/multiuser.js';
|
||||
import type { AuthUser } from '../../types.js';
|
||||
import { SseEvent } from '../sse-events.js';
|
||||
@@ -1747,6 +1748,14 @@ export function registerCaseRoutes(
|
||||
// a stale value is ignored at creation anyway).
|
||||
// An admin deleting another user's remote/Docker case clears that owner's too.
|
||||
const forgetCaseDefault = async (caseOwner?: string): Promise<void> => {
|
||||
// The shared run options (syncCaseState) are the admin's server-wide map: a non-admin's own case of
|
||||
// the same name must leave them alone. Best-effort, like the default group.
|
||||
if (isAdmin(req)) {
|
||||
await mutateSettingsFile((current) => {
|
||||
const rest = dropCaseSettings(current.caseSettings, name);
|
||||
return rest ? { ...current, caseSettings: rest } : undefined;
|
||||
}).catch(() => undefined);
|
||||
}
|
||||
const owners = new Set([ownerLayoutKey(ownerFor(req)), ...(caseOwner ? [ownerLayoutKey(caseOwner)] : [])]);
|
||||
for (const owner of owners) {
|
||||
try {
|
||||
@@ -2015,6 +2024,7 @@ export function registerCaseRoutes(
|
||||
// The settings.json values this rename replaced, set only once it wrote them. A failure puts back
|
||||
// these keys alone, so a settings save that landed in between is kept.
|
||||
let settingsUndo: Record<string, unknown> | undefined;
|
||||
let sharedRenamed = false;
|
||||
try {
|
||||
// settings.json is server-wide and caseOrder/lastUsedCase name the admin's cases, so only the
|
||||
// admin's rename touches it (a non-admin's own rename must not rewrite someone else's list).
|
||||
@@ -2023,12 +2033,24 @@ export function registerCaseRoutes(
|
||||
const { before, after } = await mutateSettingsFile((existing) => {
|
||||
const order = renameInOrder(existing.caseOrder, name, newName);
|
||||
const last = existing.lastUsedCase === name ? newName : undefined;
|
||||
if (!order && !last) return undefined;
|
||||
return { ...existing, ...(order ? { caseOrder: order } : {}), ...(last ? { lastUsedCase: last } : {}) };
|
||||
const picked = existing.lastPickedCase === name ? newName : undefined;
|
||||
// The shared run options (syncCaseState) follow the case to its new name.
|
||||
const shared = renameCaseSettings(existing.caseSettings, name, newName);
|
||||
if (!order && !last && !picked && !shared) return undefined;
|
||||
return {
|
||||
...existing,
|
||||
...(order ? { caseOrder: order } : {}),
|
||||
...(last ? { lastUsedCase: last } : {}),
|
||||
...(picked ? { lastPickedCase: picked } : {}),
|
||||
...(shared ? { caseSettings: shared } : {}),
|
||||
};
|
||||
});
|
||||
if (after) {
|
||||
const keys = ['caseOrder', 'lastUsedCase'].filter((k) => after[k] !== before[k]);
|
||||
// caseSettings is undone by renaming its entry back (below), not by restoring the whole map, so a
|
||||
// per-case save from another device that landed since is kept.
|
||||
const keys = ['caseOrder', 'lastUsedCase', 'lastPickedCase'].filter((k) => after[k] !== before[k]);
|
||||
settingsUndo = Object.fromEntries(keys.map((k) => [k, before[k]]));
|
||||
sharedRenamed = after.caseSettings !== before.caseSettings;
|
||||
}
|
||||
}
|
||||
if (isLinked) {
|
||||
@@ -2049,7 +2071,12 @@ export function registerCaseRoutes(
|
||||
// The folder moved but the registries did not follow: put settings.json and the folder back
|
||||
// rather than leave a case whose name and folder disagree.
|
||||
const undo = settingsUndo;
|
||||
if (undo) await mutateSettingsFile((current) => ({ ...current, ...undo })).catch(() => {});
|
||||
if (undo) {
|
||||
await mutateSettingsFile((current) => {
|
||||
const back = sharedRenamed ? renameCaseSettings(current.caseSettings, newName, name) : null;
|
||||
return { ...current, ...undo, ...(back ? { caseSettings: back } : {}) };
|
||||
}).catch(() => {});
|
||||
}
|
||||
if (moveFolder) {
|
||||
const movedBack = await fs.rename(newPath, oldPath).then(
|
||||
() => true,
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
/**
|
||||
* @fileoverview File browser and streaming routes.
|
||||
* Provides directory listing, file content preview, raw file serving, tail
|
||||
* streaming, and the File Viewer edit-mode write path (edit=1 read +
|
||||
* streaming, 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).
|
||||
* 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).
|
||||
*/
|
||||
|
||||
import { FastifyInstance, type FastifyReply } from 'fastify';
|
||||
@@ -19,6 +21,7 @@ import type {
|
||||
FilesystemBrowseEntry,
|
||||
FilesystemBrowseRoot,
|
||||
FilesystemPreviewKind,
|
||||
FileDeleteData,
|
||||
FileWriteData,
|
||||
} from '../../types.js';
|
||||
import { ApiErrorCode, createErrorResponse, getErrorMessage } from '../../types.js';
|
||||
@@ -2000,6 +2003,103 @@ 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 };
|
||||
|
||||
@@ -49,6 +49,7 @@ import {
|
||||
SETTINGS_PATH,
|
||||
} from '../route-helpers.js';
|
||||
import { mutateSettingsFile, settingsWriteErrorResponse } from '../settings-writer.js';
|
||||
import { mergeCaseSettings, SharedCaseLimitError } from '../case-state-sync.js';
|
||||
import { SseEvent } from '../sse-events.js';
|
||||
import { getInstallInfo, checkForUpdate, startUpdate, getUpdateStatusForApi } from '../self-update.js';
|
||||
|
||||
@@ -160,8 +161,8 @@ export function resolveSpanUrl(hostHeader: string | undefined, fallbackPort = '3
|
||||
* What GET /api/settings answers a NON-ADMIN in multi-user mode: settings.json minus what
|
||||
* belongs to the server's admin rather than to the reader. `voiceSettings.apiKey` is the
|
||||
* Deepgram key an admin saved, a credential (the reader's browser seeded it into its own
|
||||
* voice config and dictated on the admin's account); `caseOrder` and `lastUsedCase` name the
|
||||
* admin's cases. Everything else stays readable, because the page reads the synced feature
|
||||
* voice config and dictated on the admin's account); `caseOrder`, `lastUsedCase`, `lastPickedCase` and `caseSettings` name
|
||||
* the admin's cases. Everything else stays readable, because the page reads the synced feature
|
||||
* switches from it. Writes are admin-only there (PUT /api/settings). Exported for unit testing.
|
||||
*/
|
||||
export function settingsForNonAdmin(settings: unknown): unknown {
|
||||
@@ -169,6 +170,8 @@ export function settingsForNonAdmin(settings: unknown): unknown {
|
||||
const out: Record<string, unknown> = { ...(settings as Record<string, unknown>) };
|
||||
delete out.caseOrder;
|
||||
delete out.lastUsedCase;
|
||||
delete out.lastPickedCase;
|
||||
delete out.caseSettings;
|
||||
const voice = out.voiceSettings;
|
||||
if (voice && typeof voice === 'object' && !Array.isArray(voice) && 'apiKey' in voice) {
|
||||
const voiceOut: Record<string, unknown> = { ...(voice as Record<string, unknown>) };
|
||||
@@ -1141,8 +1144,18 @@ export function registerSystemRoutes(
|
||||
// It must never write tunnelEnabled:true: a concurrent stop has written false by now,
|
||||
// and true on disk would show a stopped tunnel as on. The stored value stays as it is.
|
||||
if (settings.tunnelEnabled === true && !tunnelEnableAuthorized) delete settingsToStore.tunnelEnabled;
|
||||
const { before: existing } = await mutateSettingsFile((current) => ({ ...current, ...settingsToStore }));
|
||||
const merged = { ...existing, ...settingsToStore };
|
||||
// caseSettings merges per case inside the serialised writer, so two devices saving different cases both land.
|
||||
const { caseSettings: incomingCases, ...plainSettings } = settingsToStore;
|
||||
// The map is read and written only while syncCaseState is on, so a PUT that carries it with the setting off
|
||||
// (as stored, or as this same PUT sets it) leaves the stored map alone.
|
||||
const { before: existing, after } = await mutateSettingsFile((current) => ({
|
||||
...current,
|
||||
...plainSettings,
|
||||
...(incomingCases && (plainSettings.syncCaseState ?? current.syncCaseState) === true
|
||||
? { caseSettings: mergeCaseSettings(current.caseSettings, incomingCases as never) }
|
||||
: {}),
|
||||
}));
|
||||
const merged = after ?? { ...existing, ...plainSettings };
|
||||
|
||||
// tmux 3.7+ resizes tracked panes; older versions apply this to new panes.
|
||||
// Already-evicted history cannot be recovered on either version.
|
||||
@@ -1244,6 +1257,12 @@ export function registerSystemRoutes(
|
||||
|
||||
return {};
|
||||
} catch (err) {
|
||||
if (err instanceof SharedCaseLimitError) {
|
||||
throw Object.assign(err, {
|
||||
statusCode: 400,
|
||||
body: createErrorResponse(ApiErrorCode.INVALID_INPUT, err.message),
|
||||
});
|
||||
}
|
||||
return settingsWriteErrorResponse(err);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1461,6 +1461,18 @@ export const SettingsUpdateSchema = z
|
||||
defaultClaudeMdPath: z.string().max(500).optional(),
|
||||
defaultWorkingDir: z.string().max(500).optional(),
|
||||
lastUsedCase: z.string().max(200).optional(),
|
||||
/** The case a person last picked in a case picker; what other devices follow while `syncCaseState` is on. */
|
||||
lastPickedCase: z.string().max(200).optional(),
|
||||
/**
|
||||
* Opt-in, SYNCED, default OFF: per-case run options (Agent Teams, Opus 1M) are kept here instead of
|
||||
* only in each browser, and a device follows the selected case another device chose.
|
||||
*/
|
||||
syncCaseState: z.boolean().optional(),
|
||||
/** Per-case run options, by case name; read and written only while `syncCaseState` is on. */
|
||||
caseSettings: z
|
||||
.record(z.string().max(200), z.object({ agentTeams: z.boolean(), opusContext1m: z.boolean() }).strict())
|
||||
.refine((m) => Object.keys(m).length <= 500, 'too many cases')
|
||||
.optional(),
|
||||
// Feature toggles
|
||||
ralphTrackerEnabled: z.boolean().optional(),
|
||||
subagentTrackingEnabled: z.boolean().optional(),
|
||||
@@ -1783,6 +1795,8 @@ 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)
|
||||
|
||||
@@ -13,9 +13,10 @@
|
||||
* - `{"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 RUNNING transcript
|
||||
* (each frame supersedes the previous one — they are not deltas to concatenate),
|
||||
* and `TranscriptEndpoint` promotes the pending interim to final.
|
||||
* - 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.
|
||||
* - `{"type":"CloseStream"}` finalizes; the endpoint frame that follows is the
|
||||
* last transcript, so `finalize()` waits briefly for it rather than closing.
|
||||
*
|
||||
@@ -244,7 +245,7 @@ export class VoiceStreamRelay {
|
||||
switch (msg.type) {
|
||||
case 'TranscriptText':
|
||||
case 'TranscriptInterim': {
|
||||
// Each frame is the whole running transcript, not a delta.
|
||||
// Each frame is the current utterance so far, not a delta; endpoints commit it.
|
||||
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 = { _getDeepgramConfig: () => ({}), _saveDeepgramConfig() {}, refreshClaudeStatus: () => Promise.resolve() };
|
||||
var VoiceInput = { SILENCE_TIMEOUT_SECONDS: [0, 3, 6, 15, 30], _getDeepgramConfig: () => ({}), _saveDeepgramConfig() {}, refreshClaudeStatus: () => Promise.resolve() };
|
||||
var FocusTrap = function () { this.activate = () => {}; this.deactivate = () => {}; };
|
||||
var DEFAULT_VOICE_KEYTERMS = '';`,
|
||||
...SCRIPTS,
|
||||
|
||||
@@ -173,7 +173,11 @@ describe('createConfigBackup', () => {
|
||||
'hostId',
|
||||
],
|
||||
['users.json', (n: number) => JSON.stringify({ users: [{ name: 'a', role: 'admin', lastLoginAt: n }] }), 'role'],
|
||||
['settings.json', (n: number) => JSON.stringify({ displayName: 'x', lastUsedCase: `case-${n}` }), 'displayName'],
|
||||
[
|
||||
'settings.json',
|
||||
(n: number) => JSON.stringify({ displayName: 'x', lastUsedCase: `case-${n}`, lastPickedCase: `case-${n}` }),
|
||||
'displayName',
|
||||
],
|
||||
[
|
||||
'intents.json',
|
||||
(n: number) => JSON.stringify({ profiles: [{ key: 'k', goals: 'g', updatedAt: n, recentPrompts: [`p${n}`] }] }),
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
/**
|
||||
* @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,6 +30,9 @@ 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 {
|
||||
@@ -105,6 +108,7 @@ function fakeElement(initialClasses: string[] = []): FakeElement {
|
||||
click() {
|
||||
clickHandler?.();
|
||||
},
|
||||
querySelector: () => null,
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
@@ -0,0 +1,264 @@
|
||||
/**
|
||||
* @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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,141 @@
|
||||
// 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?.()');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* @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);
|
||||
});
|
||||
@@ -31,7 +31,6 @@ import {
|
||||
LocalModelCatalogSchema,
|
||||
STOCK_LOCAL_MODEL_CATALOG,
|
||||
} from '../src/config/local-model-catalog.js';
|
||||
import { MAX_ALTERNATIVES } from '../src/local-model/recommend.js';
|
||||
import {
|
||||
RUNTIME_CTX_CHECKPOINTS,
|
||||
RUNTIME_HOST_PROMPT_CACHE_MIB,
|
||||
@@ -64,11 +63,9 @@ const GIB = 1024 * MIB;
|
||||
|
||||
/**
|
||||
* The architecture of every model the stock catalogue lists, read from its config.json on
|
||||
* Hugging Face (2026-11-08; Muse Glimmer on 2026-11-10): full-attention layers, their K/V heads
|
||||
* and head size, and the layers whose cache does not grow with the context (Gated DeltaNet
|
||||
* layers with their value and key heads, or sliding-window layers with their window).
|
||||
* Fine-tunes share their base's: the config.json of Ornith 1.0 9B (2026-11-10) is that of
|
||||
* Qwen3.5-9B in every field below, and the GGUF header of its build says the same.
|
||||
* Hugging Face (2026-11-08): full-attention layers, their K/V heads and head size, and the
|
||||
* layers whose cache does not grow with the context (Gated DeltaNet layers with their value
|
||||
* and key heads, or sliding-window layers with their window). Fine-tunes share their base's.
|
||||
*/
|
||||
const ARCHITECTURES = {
|
||||
'Qwen3.5-9B': { attentionLayers: 8, kvHeads: 4, headDim: 256, linear: { layers: 24, vHeads: 32, kHeads: 16 } },
|
||||
@@ -81,8 +78,6 @@ const ARCHITECTURES = {
|
||||
linear: { layers: 36, vHeads: 64, kHeads: 16 },
|
||||
},
|
||||
'North-Mini-Code-1.0': { attentionLayers: 13, kvHeads: 4, headDim: 128, sliding: { layers: 36, window: 4096 } },
|
||||
// 52 layers, three in four on a sliding window (the GGUF header's `sliding_window_pattern`).
|
||||
'Muse-Glimmer-30B': { attentionLayers: 13, kvHeads: 2, headDim: 128, sliding: { layers: 39, window: 2048 } },
|
||||
} as const;
|
||||
|
||||
type ArchitectureName = keyof typeof ARCHITECTURES;
|
||||
@@ -91,8 +86,7 @@ type Architecture = (typeof ARCHITECTURES)[ArchitectureName];
|
||||
/**
|
||||
* Which architecture each stock row runs, and how many tokens the runtime drafts per step with
|
||||
* its multi-token-prediction layer: 0 when the GGUF has none (`nextn_predict_layers` in the GGUF
|
||||
* header, read on 2026-11-08 from the first bytes of each model layer; on 2026-11-10 for the
|
||||
* Ornith 1.0 and Muse Glimmer rows), else the tag's own
|
||||
* header, read on 2026-11-08 from the first bytes of each model layer), else the tag's own
|
||||
* `draft_num_predict` (its params layer) or the runtime's default of 4 when the tag sets none.
|
||||
* Prediction costs a cache for that layer and one rollback copy of the recurrent state per
|
||||
* drafted token (see the catalogue header). A row the catalogue gains must be added here, or the
|
||||
@@ -100,12 +94,8 @@ type Architecture = (typeof ARCHITECTURES)[ArchitectureName];
|
||||
*/
|
||||
const ROW_ARCHITECTURE: Record<string, { architecture: ArchitectureName; draftTokens: number }> = {
|
||||
'qwen3.5:9b-q4_k_m': { architecture: 'Qwen3.5-9B', draftTokens: 0 },
|
||||
// A fine-tune of Qwen3.5-9B; its GGUF has 32 blocks and no prediction layer.
|
||||
'ornith:9b-q4_k_m': { architecture: 'Qwen3.5-9B', draftTokens: 0 },
|
||||
'mimo-v2.6:9b-q4_k_m': { architecture: 'Qwen3.5-9B', draftTokens: 0 },
|
||||
'north-mini-code-1.0:q4_k_m': { architecture: 'North-Mini-Code-1.0', draftTokens: 0 },
|
||||
// No prediction layer; the tags with a separate draft model (`-dflash`) are not listed.
|
||||
'muse-glimmer:30b-q4_k_m': { architecture: 'Muse-Glimmer-30B', draftTokens: 0 },
|
||||
// Its params layer sets `draft_num_predict` 2.
|
||||
'qwen3.6:35b-a3b-mtp-q4_k_m': { architecture: 'Qwen3.6-35B-A3B', draftTokens: 2 },
|
||||
'qwen3.6:35b-a3b': { architecture: 'Qwen3.6-35B-A3B', draftTokens: 0 },
|
||||
@@ -193,22 +183,20 @@ describe('the stock catalogue', () => {
|
||||
expect(getLocalModelCatalog()).toBe(first);
|
||||
});
|
||||
|
||||
it('lists ten models, by rank, and none of those set aside in the header', () => {
|
||||
it('lists eight models, by rank, and none of those set aside in the header', () => {
|
||||
expect(models.map((m) => m.id)).toEqual([
|
||||
'qwen3.5:9b-q4_k_m',
|
||||
'ornith:9b-q4_k_m',
|
||||
'mimo-v2.6:9b-q4_k_m',
|
||||
'north-mini-code-1.0:q4_k_m',
|
||||
'muse-glimmer:30b-q4_k_m',
|
||||
'qwen3.6:35b-a3b-mtp-q4_k_m',
|
||||
'qwen3.6:35b-a3b',
|
||||
'qwen3.8:27b-q8_0',
|
||||
'qwen3.8:27b-mtp-q4_k_m',
|
||||
'ornith-1.5:122b',
|
||||
]);
|
||||
expect(models.map((m) => m.rank)).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]);
|
||||
expect(models.map((m) => m.rank)).toEqual([1, 2, 3, 4, 5, 6, 7, 8]);
|
||||
const setAside =
|
||||
/^(bonsai-2|gpt-oss|flint|lfm2\.5|laguna|qwen3\.8-flash-next|qwen3\.5:(122b|4b)|ripple-preview|nemotron-3\.5-super|qwen3-coder-next)|^ornith-1\.5:(9b|35b|397b)|^ornith:35b|^muse-glimmer:.*(mlx|dflash)/;
|
||||
/^(bonsai-2|gpt-oss|flint|lfm2\.5|laguna|qwen3\.8-flash-next|qwen3\.5:122b)|^ornith-1\.5:(9b|35b|397b)/;
|
||||
expect(models.filter((m) => setAside.test(m.id)).map((m) => m.id)).toEqual([]);
|
||||
// The plain 9B and 27B tags are MLX builds on a Mac: their GGUF builds are listed instead.
|
||||
for (const plain of ['qwen3.5:9b', 'mimo-v2.6:9b', 'qwen3.8:27b']) {
|
||||
@@ -219,10 +207,8 @@ describe('the stock catalogue', () => {
|
||||
it('names the runner a Mac uses for each tag', () => {
|
||||
expect(Object.fromEntries(models.map((m) => [m.id, m.runner]))).toEqual({
|
||||
'qwen3.5:9b-q4_k_m': 'llamacpp',
|
||||
'ornith:9b-q4_k_m': 'llamacpp',
|
||||
'mimo-v2.6:9b-q4_k_m': 'llamacpp',
|
||||
'north-mini-code-1.0:q4_k_m': 'llamacpp',
|
||||
'muse-glimmer:30b-q4_k_m': 'llamacpp',
|
||||
'qwen3.6:35b-a3b-mtp-q4_k_m': 'llamacpp',
|
||||
// Manifest lists: a Mac takes the MLX child.
|
||||
'qwen3.6:35b-a3b': 'mlx',
|
||||
@@ -236,12 +222,8 @@ describe('the stock catalogue', () => {
|
||||
expect(Object.fromEntries(models.map((m) => [m.id, m.bytes]))).toEqual({
|
||||
// The registry's manifests as fetched on 2026-11-08, in lowercase.
|
||||
'qwen3.5:9b-q4_k_m': 6_550_825_550,
|
||||
// Fetched on 2026-11-10. No vision projector: the weights, a system prompt, the licence.
|
||||
'ornith:9b-q4_k_m': 5_629_110_568,
|
||||
'mimo-v2.6:9b-q4_k_m': 6_550_811_033,
|
||||
'north-mini-code-1.0:q4_k_m': 18_593_967_008,
|
||||
// Fetched on 2026-11-10: 16.76 GB of weights and a 1.40 GB projector.
|
||||
'muse-glimmer:30b-q4_k_m': 18_157_010_252,
|
||||
'qwen3.6:35b-a3b-mtp-q4_k_m': 22_621_314_381,
|
||||
// The MLX child of the list, and the total of a real pull on a Mac, byte for byte.
|
||||
'qwen3.6:35b-a3b': 23_105_798_956,
|
||||
@@ -272,11 +254,8 @@ describe('the stock catalogue', () => {
|
||||
it('gives every model a parameter count, an id of the default registry and a license', () => {
|
||||
expect(Object.fromEntries(models.map((m) => [m.id, [m.paramsB, m.license]]))).toEqual({
|
||||
'qwen3.5:9b-q4_k_m': [9, 'Apache-2.0'],
|
||||
'ornith:9b-q4_k_m': [9, 'MIT'],
|
||||
'mimo-v2.6:9b-q4_k_m': [9, 'MIT'],
|
||||
'north-mini-code-1.0:q4_k_m': [30, 'Apache-2.0'],
|
||||
// The card's total: a 27.9B language model and a vision encoder.
|
||||
'muse-glimmer:30b-q4_k_m': [29.6, 'Apache-2.0'],
|
||||
'qwen3.6:35b-a3b-mtp-q4_k_m': [35, 'Apache-2.0'],
|
||||
'qwen3.6:35b-a3b': [35, 'Apache-2.0'],
|
||||
'qwen3.8:27b-q8_0': [27.8, 'Apache-2.0'],
|
||||
@@ -284,12 +263,8 @@ describe('the stock catalogue', () => {
|
||||
'ornith-1.5:122b': [122, 'MIT'],
|
||||
});
|
||||
for (const model of models) expect(model.id).toMatch(LOCAL_MODEL_ID_PATTERN);
|
||||
// The three 9B rows are the ones under the 12 billion line of the `limited` verdict.
|
||||
expect(models.filter((m) => m.paramsB < 12).map((m) => m.id)).toEqual([
|
||||
'qwen3.5:9b-q4_k_m',
|
||||
'ornith:9b-q4_k_m',
|
||||
'mimo-v2.6:9b-q4_k_m',
|
||||
]);
|
||||
// The two 9B rows are the ones under the 12 billion line of the `limited` verdict.
|
||||
expect(models.filter((m) => m.paramsB < 12).map((m) => m.id)).toEqual(['qwen3.5:9b-q4_k_m', 'mimo-v2.6:9b-q4_k_m']);
|
||||
});
|
||||
|
||||
it('offers no model below the 65536 tokens a harness needs, nor beyond its own window', () => {
|
||||
@@ -301,14 +276,11 @@ describe('the stock catalogue', () => {
|
||||
|
||||
it('has a speed figure only where one was measured or scaled from a measurement', () => {
|
||||
expect(Object.fromEntries(models.map((m) => [m.id, m.speedPer100Gbps ?? null]))).toEqual({
|
||||
// 37.1 tokens per second on 400 GB/s, and the same for the two fine-tunes of the same size.
|
||||
// 37.1 tokens per second on 400 GB/s, and the same for the fine-tune of the same size.
|
||||
'qwen3.5:9b-q4_k_m': 9.3,
|
||||
'ornith:9b-q4_k_m': 9.3,
|
||||
'mimo-v2.6:9b-q4_k_m': 9.3,
|
||||
// The 35B-A3B's llama.cpp figure: both have 3 billion active parameters.
|
||||
'north-mini-code-1.0:q4_k_m': 13.6,
|
||||
// The dense 27B's figure: the weights of the two are the same size.
|
||||
'muse-glimmer:30b-q4_k_m': 2.95,
|
||||
// 54.5 on 400 GB/s with llama.cpp.
|
||||
'qwen3.6:35b-a3b-mtp-q4_k_m': 13.6,
|
||||
// 67.8 on 400 GB/s with MLX.
|
||||
@@ -330,13 +302,6 @@ describe('the stock catalogue', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('holds no more models than one window lists: the pick and MAX_ALTERNATIVES others', () => {
|
||||
// recommend() cuts the list at MAX_ALTERNATIVES. While the catalogue stays under it, a Mac
|
||||
// is shown every model that fits and the install accepts every one of them. A catalogue
|
||||
// that outgrows the bound must fail here, so the bound is raised (or kept) on purpose.
|
||||
expect(models.length - 1).toBeLessThanOrEqual(MAX_ALTERNATIVES);
|
||||
});
|
||||
|
||||
it('names the oldest macOS and its schema version', () => {
|
||||
expect(STOCK_LOCAL_MODEL_CATALOG.minMacosMajor).toBe(14);
|
||||
expect(STOCK_LOCAL_MODEL_CATALOG.schema).toBe(1);
|
||||
@@ -383,12 +348,9 @@ describe('the memory figures of the stock rows: each row has its own', () => {
|
||||
expect(Object.fromEntries(models.map((m) => [m.id, figuresOf(m)]))).toEqual({
|
||||
// State x (1 live + 8 checkpoints) + 1.0 GB + 1 GiB.
|
||||
'qwen3.5:9b-q4_k_m': [17_408, 2_547_960_320, 0],
|
||||
'ornith:9b-q4_k_m': [17_408, 2_547_960_320, 0],
|
||||
'mimo-v2.6:9b-q4_k_m': [17_408, 2_547_960_320, 0],
|
||||
// The sliding window x (1 live + 8 checkpoints) + 1.0 GB + 1 GiB.
|
||||
'north-mini-code-1.0:q4_k_m': [14_144, 3_698_117_120, 0],
|
||||
// The same rule: 39 layers x 2,560 cells x 544 bytes = 54,312,960 per copy.
|
||||
'muse-glimmer:30b-q4_k_m': [7_072, 2_562_558_464, 0],
|
||||
// State x (1 live + 2 rollback copies + 8 checkpoints) + 1.0 GB + 1 GiB; the prediction
|
||||
// layer's cache in f16 per token. The 27B rows below draft 4 tokens: 4 rollback copies.
|
||||
'qwen3.6:35b-a3b-mtp-q4_k_m': [12_928, 2_798_242_304, 0],
|
||||
@@ -412,9 +374,9 @@ describe('the memory figures of the stock rows: each row has its own', () => {
|
||||
byFigures.set(key, [...(byFigures.get(key) ?? []), model]);
|
||||
}
|
||||
const shared = [...byFigures.values()].filter((group) => group.length > 1);
|
||||
// The three 9B rows (a base and two fine-tunes of it) and the two 27B rows (two quantizations of one model).
|
||||
// The two 9B rows (a fine-tune and its base) and the two 27B rows (two quantizations of one model).
|
||||
expect(shared.map((group) => group.map((m) => m.id))).toEqual([
|
||||
['qwen3.5:9b-q4_k_m', 'ornith:9b-q4_k_m', 'mimo-v2.6:9b-q4_k_m'],
|
||||
['qwen3.5:9b-q4_k_m', 'mimo-v2.6:9b-q4_k_m'],
|
||||
['qwen3.8:27b-q8_0', 'qwen3.8:27b-mtp-q4_k_m'],
|
||||
]);
|
||||
for (const group of shared) {
|
||||
|
||||
@@ -12,9 +12,9 @@
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { getLocalModelCatalog, LocalModelCatalogSchema } from '../src/config/local-model-catalog.js';
|
||||
import { getLocalModelCatalog } from '../src/config/local-model-catalog.js';
|
||||
import { formatGb, memClassGb } from '../src/local-model/format.js';
|
||||
import { MAX_ALTERNATIVES, modelFitsBudget, recommend } from '../src/local-model/recommend.js';
|
||||
import { modelFitsBudget, recommend } from '../src/local-model/recommend.js';
|
||||
import type { HardwareFacts, HarnessFact } from '../src/types.js';
|
||||
|
||||
/** A Mac with Apple silicon and `gb` GB of memory as sold. */
|
||||
@@ -72,46 +72,18 @@ describe('modelFitsBudget', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('does not depend on what the recommendation lists: a model left out for disk space still fits', () => {
|
||||
// The recommendation lists every model that fits the memory, unless its download would
|
||||
// leave under 10 GiB on the disk. With 40 GB free on a 128 GB Mac that leaves out the two
|
||||
// largest downloads, which fit the memory all the same.
|
||||
const hw = { ...mac(128, 'Apple M5 Max'), freeDiskBytes: 40_000_000_000 };
|
||||
it('does not depend on the ranking: a model that fits and is listed nowhere still fits', () => {
|
||||
// On a 128 GB Mac more models fit than the recommendation lists.
|
||||
const hw = mac(128, 'Apple M5 Max');
|
||||
const offered = recommend(hw, catalog, harnessFacts());
|
||||
const listed = new Set([offered.pick, ...offered.alternatives].map((row) => row?.modelId));
|
||||
const unlisted = catalog.models.filter((model) => !listed.has(model.id));
|
||||
expect(unlisted.map((model) => model.id)).toEqual(['qwen3.8:27b-q8_0', 'ornith-1.5:122b']);
|
||||
expect(unlisted.length).toBeGreaterThan(0);
|
||||
for (const model of unlisted) {
|
||||
expect(modelFitsBudget(hw, catalog, model.id, model.contexts.at(-1) as number), model.id).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('does not depend on the ranking: a model cut off by the length of the list still fits', () => {
|
||||
// A catalogue longer than the list: the stock rows plus six more copies of its smallest.
|
||||
const smallest = catalog.models[0];
|
||||
const long = LocalModelCatalogSchema.parse({
|
||||
...structuredClone(catalog),
|
||||
models: [
|
||||
...structuredClone(catalog.models),
|
||||
...Array.from({ length: 6 }, (_, index) => ({
|
||||
...structuredClone(smallest),
|
||||
id: `extra-${index + 1}:9b`,
|
||||
rank: -1 - index,
|
||||
})),
|
||||
],
|
||||
});
|
||||
expect(long.models.length).toBeGreaterThan(1 + MAX_ALTERNATIVES);
|
||||
const hw = mac(512, 'Apple M5 Max');
|
||||
const offered = recommend(hw, long, harnessFacts());
|
||||
const listed = new Set([offered.pick, ...offered.alternatives].map((row) => row?.modelId));
|
||||
expect(listed.size).toBe(1 + MAX_ALTERNATIVES);
|
||||
const unlisted = long.models.filter((model) => !listed.has(model.id));
|
||||
expect(unlisted.length).toBe(long.models.length - 1 - MAX_ALTERNATIVES);
|
||||
for (const model of unlisted) {
|
||||
expect(modelFitsBudget(hw, long, model.id, model.contexts.at(-1) as number), model.id).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('rules out a model that is too large, and one the catalogue does not have', () => {
|
||||
expect(modelFitsBudget(mac(48), catalog, 'ornith-1.5:122b', 131072)).toBe(false);
|
||||
expect(modelFitsBudget(mac(48), catalog, 'no-such:1b', 65536)).toBe(false);
|
||||
|
||||
@@ -487,12 +487,8 @@ describe('needBytes', () => {
|
||||
it('states the need of every stock row at its largest context', () => {
|
||||
expect(Object.fromEntries(STOCK.models.map((m) => [m.id, needBytes(m, m.contexts[0])]))).toEqual({
|
||||
'qwen3.5:9b-q4_k_m': 11_380_487_246,
|
||||
// 0.92 GB under the other two 9B builds: it has no vision projector.
|
||||
'ornith:9b-q4_k_m': 10_458_772_264,
|
||||
'mimo-v2.6:9b-q4_k_m': 11_380_472_729,
|
||||
'north-mini-code-1.0:q4_k_m': 25_999_848_864,
|
||||
// At its own window of 131072 tokens: 18.2 GB + 0.9 GB of K/V cache + 2.6 GB.
|
||||
'muse-glimmer:30b-q4_k_m': 21_646_509_900,
|
||||
'qwen3.6:35b-a3b-mtp-q4_k_m': 28_808_554_317,
|
||||
'qwen3.6:35b-a3b': 35_955_462_956,
|
||||
'qwen3.8:27b-q8_0': 44_292_142_594,
|
||||
@@ -548,9 +544,6 @@ describe('the fit grade', () => {
|
||||
// ---------- 2. The two real Macs ----------
|
||||
|
||||
describe('the MacBook Pro (Mac17,13: Apple M5 Pro, 48 GB)', () => {
|
||||
/** The same Mac without the probe: 48 GiB and the chip are all the fit and the speed depend on. */
|
||||
const hw48 = mac(48, 'Apple M5 Pro');
|
||||
|
||||
/**
|
||||
* Expected: Qwen3.6 35B-A3B at 131,072 tokens with OpenCode, a TIGHT fit, 67.7 tokens per
|
||||
* second, "Recommended for this Mac".
|
||||
@@ -649,7 +642,7 @@ describe('the MacBook Pro (Mac17,13: Apple M5 Pro, 48 GB)', () => {
|
||||
expect(result.notes).toEqual([]);
|
||||
});
|
||||
|
||||
it('lists every other model that fits, each at the largest context it fits at, the fast ones first', async () => {
|
||||
it('lists four other models that fit, each at the largest context it fits at, the fast ones first', async () => {
|
||||
const result = recommend(await macbook(), STOCK, ALL_INSTALLED);
|
||||
expect(
|
||||
result.alternatives.map((c) => [c.modelId, c.context, c.fit, c.speed, c.tokensPerSecond ?? null, c.tag?.kind])
|
||||
@@ -658,21 +651,16 @@ describe('the MacBook Pro (Mac17,13: Apple M5 Pro, 48 GB)', () => {
|
||||
['qwen3.6:35b-a3b-mtp-q4_k_m', 262144, 'good', 'ok', 41.8, undefined],
|
||||
['north-mini-code-1.0:q4_k_m', 262144, 'good', 'ok', 41.8, undefined],
|
||||
['mimo-v2.6:9b-q4_k_m', 131072, 'good', 'ok', 28.6, 'small'],
|
||||
['ornith:9b-q4_k_m', 131072, 'good', 'ok', 28.6, 'small'],
|
||||
['qwen3.5:9b-q4_k_m', 131072, 'good', 'ok', 28.6, 'small'],
|
||||
// The dense models compute to 2.95 x 3.07 = 9.1 tokens per second on this chip (5.2 in
|
||||
// 8 bits): slow, so they come after every fast model, in the order of their rank.
|
||||
['qwen3.8:27b-mtp-q4_k_m', 262144, 'tight', 'slow', 9.1, 'tight'],
|
||||
['qwen3.8:27b-q8_0', 65536, 'tight', 'slow', 5.2, 'tight'],
|
||||
['muse-glimmer:30b-q4_k_m', 131072, 'good', 'slow', 9.1, 'slow'],
|
||||
]);
|
||||
// Only the tested row rests on a measurement of that model.
|
||||
expect(new Set(result.alternatives.map((c) => c.needSource))).toEqual(new Set(['estimated']));
|
||||
// Every model of the catalogue that fits is on the list: nothing is cut off after four.
|
||||
const fitting = STOCK.models.filter((m) => m.contexts.some((ctx) => modelFitsBudget(hw48, STOCK, m.id, ctx)));
|
||||
expect(rows(result).map((c) => c.modelId).sort()).toEqual(fitting.map((m) => m.id).sort());
|
||||
expect(result.alternatives.map((c) => c.needSource)).toEqual(['estimated', 'estimated', 'estimated', 'estimated']);
|
||||
// The dense 27B fits too (32.1 GB at 262144) but computes to 2.95 x 3.07 = 9.1 tokens per
|
||||
// second here: slow, so it ranks after four fast models and is not listed.
|
||||
expect(modelFitsBudget(await macbook(), STOCK, 'qwen3.8:27b-mtp-q4_k_m', 262144)).toBe(true);
|
||||
expect(order(result).some((entry) => entry.startsWith('qwen3.8:27b'))).toBe(false);
|
||||
// The 122B fits at no context.
|
||||
expect(order(result).some((entry) => entry.startsWith('ornith-1.5'))).toBe(false);
|
||||
expect(order(result).some((entry) => entry.startsWith('ornith'))).toBe(false);
|
||||
});
|
||||
|
||||
it('would get the same model as "Best fit", with the estimated speed, had nobody tested it', async () => {
|
||||
@@ -704,68 +692,41 @@ describe('the MacBook Pro (Mac17,13: Apple M5 Pro, 48 GB)', () => {
|
||||
['qwen3.8:27b-mtp-q4_k_m', 'unknown'],
|
||||
['qwen3.8:27b-q8_0', 'unknown'],
|
||||
['qwen3.6:35b-a3b-mtp-q4_k_m', 'unknown'],
|
||||
['muse-glimmer:30b-q4_k_m', 'unknown'],
|
||||
['north-mini-code-1.0:q4_k_m', 'unknown'],
|
||||
['mimo-v2.6:9b-q4_k_m', 'unknown'],
|
||||
['ornith:9b-q4_k_m', 'unknown'],
|
||||
['qwen3.5:9b-q4_k_m', 'unknown'],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the Mac mini (Mac16,10: Apple M4, 16 GB)', () => {
|
||||
/**
|
||||
* Expected: `limited` with Ornith 1.0 9B at 65,536 tokens, a TIGHT fit, on the estimated
|
||||
* budget, and MiMo V2.6 9B at 65,536 tokens, also tight, once the runtime measured its budget.
|
||||
* Expected: verdict `none` (reason `memory`) on the estimated budget, and `limited` with
|
||||
* MiMo V2.6 9B (a GGUF build) at 65,536 tokens, a TIGHT fit, once the runtime measured its
|
||||
* budget.
|
||||
*
|
||||
* Why. The estimated budget is 9.67 GiB (10.4 GB: 2/3 of 16 GiB minus 1). A 9B on llama.cpp
|
||||
* needs its weights + 1.14 GB for a q8_0 K/V cache of 65,536 tokens + 2.55 GB beside them (its
|
||||
* recurrent state live and in 8 context checkpoints, the runner, and the 1 GiB prompt cache
|
||||
* the runtime allows). MiMo and its base are 6.55 GB (a vision projector of 0.92 GB
|
||||
* included): 10.2 GB, 0.987 of the estimate, just past the 0.98 a tight fit may reach. The
|
||||
* Ornith build has no projector, 5.63 GB: 9.3 GB, 0.898 of the estimate. The runtime measured
|
||||
* 11.8 GiB on this Mac; the 6 GiB reserve leaves 10 GiB (10.7 GB) of it, which admits all
|
||||
* three, and the rank puts MiMo first.
|
||||
* Why. The estimated budget is 9.67 GiB (10.4 GB: 2/3 of 16 GiB minus 1). The 9B on
|
||||
* llama.cpp needs 6.55 GB of weights + 1.14 GB for a q8_0 K/V cache of 65,536 tokens +
|
||||
* 2.55 GB beside them (its recurrent state live and in 8 context checkpoints, the runner, and
|
||||
* the 1 GiB prompt cache the runtime allows) = 10.2 GB: 0.987 of the estimate, just past the
|
||||
* 0.98 a tight fit may reach. The runtime measured 11.8 GiB on this Mac; the 6 GiB reserve
|
||||
* leaves 10 GiB (10.7 GB) of it, and the same 10.2 GB is 0.954 of that.
|
||||
*
|
||||
* Every figure of these rows is computed from the model's architecture, none was measured
|
||||
* (`needSource: 'estimated'`), and no 9B was ever run on such a Mac by us.
|
||||
* Every figure of this row is computed from the model's architecture, none was measured
|
||||
* (`needSource: 'estimated'`), so this Mac is on the edge either way. Only a run of the 9B on
|
||||
* it settles which side.
|
||||
*/
|
||||
it('gets Ornith 1.0 9B at 65536 tokens on the estimated budget, tight: the other 9B builds need 0.987 of it', async () => {
|
||||
it('gets no model on the estimated budget: the 9B needs 0.987 of it', async () => {
|
||||
const hw = await macMini();
|
||||
expect(hw.chip).toBe('Apple M4');
|
||||
expect(hw.memClassGb).toBe(16);
|
||||
|
||||
const result = recommend(hw, STOCK, ALL_INSTALLED);
|
||||
|
||||
expect(result).toMatchObject({ verdict: 'limited', budgetSource: 'estimated' });
|
||||
expect(result.headline).toEqual({ text: LOCAL_MODEL_LABELS.headlineLimited });
|
||||
expect(result.notes).toEqual([{ text: LOCAL_MODEL_LABELS.noteUntested }]);
|
||||
expect(result).toMatchObject({ verdict: 'none', reason: 'memory', budgetSource: 'estimated' });
|
||||
expect(result.headline).toEqual({ text: LOCAL_MODEL_LABELS.noneMemory, vars: { memory: '16' } });
|
||||
expect(result.pick).toBeUndefined();
|
||||
// floor(17,179,869,184 x 2/3) - 1 GiB.
|
||||
expect(result.budgetBytes).toBe(10_379_504_298);
|
||||
expect(result.budgetGb).toBe('10.4');
|
||||
expect(result.pick).toMatchObject({
|
||||
modelId: 'ornith:9b-q4_k_m',
|
||||
modelLabel: 'Ornith 1.0 9B',
|
||||
license: 'MIT',
|
||||
context: 65536,
|
||||
cli: 'opencode',
|
||||
// 5,629,110,568 + 17,408 x 65,536 + 2,547,960,320.
|
||||
needBytes: 9_317_921_576,
|
||||
needGb: '9.3',
|
||||
memory: { modelBytes: 8_177_070_888, contextBytes: 1_140_850_688, peakBytes: 0 },
|
||||
downloadGb: '5.6',
|
||||
budgetShare: 0.898,
|
||||
fit: 'tight',
|
||||
// 9.3 x 120 / 100.
|
||||
speed: 'ok',
|
||||
tokensPerSecond: 11.2,
|
||||
tested: false,
|
||||
needSource: 'estimated',
|
||||
tag: { kind: 'best-tight', label: 'Best fit, tight' },
|
||||
});
|
||||
// It is the only model that fits, and only at this context: at 131072 it needs 10.5 GB.
|
||||
expect(order(result)).toEqual(['ornith:9b-q4_k_m@65536']);
|
||||
expect(modelFitsBudget(hw, STOCK, 'ornith:9b-q4_k_m', 131072)).toBe(false);
|
||||
const nineB = stockModel('mimo-v2.6:9b-q4_k_m');
|
||||
// 6,550,811,033 + 17,408 x 65,536 + 2,547,960,320.
|
||||
expect(needBytes(nineB, 65536)).toBe(10_239_622_041);
|
||||
@@ -773,7 +734,7 @@ describe('the Mac mini (Mac16,10: Apple M4, 16 GB)', () => {
|
||||
expect(needBytes(nineB, 65536) / result.budgetBytes).toBeLessThan(0.99);
|
||||
});
|
||||
|
||||
it('would need 11.4 GB for MiMo at 131072 tokens, more than the whole budget', async () => {
|
||||
it('would need 11.4 GB at 131072 tokens, more than the whole budget', async () => {
|
||||
const hw = await macMini();
|
||||
const nineB = stockModel('mimo-v2.6:9b-q4_k_m');
|
||||
const budget = deviceBudget(hw).budgetBytes;
|
||||
@@ -783,7 +744,7 @@ describe('the Mac mini (Mac16,10: Apple M4, 16 GB)', () => {
|
||||
expect(modelFitsBudget(hw, STOCK, 'mimo-v2.6:9b-q4_k_m', 65536)).toBe(false);
|
||||
});
|
||||
|
||||
it('gets MiMo with the budget its runtime measured: at 65536 tokens, tight', async () => {
|
||||
it('gets a small model with the budget its runtime measured: the 9B at 65536 tokens, tight', async () => {
|
||||
// The runtime reported 11.8 GiB here; the 6 GiB reserve leaves 10 GiB (10.7 GB) of it.
|
||||
const result = recommend(await macMini(), STOCK, ALL_INSTALLED, { measuredTotalBytes: MACMINI_MEASURED_TOTAL });
|
||||
expect(result).toMatchObject({
|
||||
@@ -812,20 +773,13 @@ describe('the Mac mini (Mac16,10: Apple M4, 16 GB)', () => {
|
||||
needSource: 'estimated',
|
||||
tag: { kind: 'best-tight', label: 'Best fit, tight' },
|
||||
});
|
||||
// The other two 9B builds follow by rank. The Ornith build is small enough for 131072
|
||||
// tokens here (10.5 GB, 0.974), and a model is listed at the largest context it fits at.
|
||||
expect(order(result)).toEqual([
|
||||
'mimo-v2.6:9b-q4_k_m@65536',
|
||||
'ornith:9b-q4_k_m@131072',
|
||||
'qwen3.5:9b-q4_k_m@65536',
|
||||
]);
|
||||
// Its base model, the same figures, is the one alternative.
|
||||
expect(order(result)).toEqual(['mimo-v2.6:9b-q4_k_m@65536', 'qwen3.5:9b-q4_k_m@65536']);
|
||||
});
|
||||
|
||||
it('gets the same with Claude Code as its only coding agent, which also needs 65536 tokens', async () => {
|
||||
const hw = await macMini();
|
||||
const estimated = recommend(hw, STOCK, [fact('claude')]);
|
||||
expect(estimated.verdict).toBe('limited');
|
||||
expect(estimated.pick).toMatchObject({ modelId: 'ornith:9b-q4_k_m', context: 65536, cli: 'claude' });
|
||||
expect(recommend(hw, STOCK, [fact('claude')])).toMatchObject({ verdict: 'none', reason: 'memory' });
|
||||
const measured = recommend(hw, STOCK, [fact('claude')], { measuredTotalBytes: MACMINI_MEASURED_TOTAL });
|
||||
expect(measured.verdict).toBe('limited');
|
||||
expect(measured.pick).toMatchObject({ modelId: 'mimo-v2.6:9b-q4_k_m', context: 65536, cli: 'claude' });
|
||||
@@ -837,26 +791,22 @@ describe('every memory size, with the stock catalogue', () => {
|
||||
// size. A change of a catalogue row or of a threshold that moves one of these answers has to
|
||||
// change this table on purpose. docs/local-models.md has the same table for people.
|
||||
const EXPECTED: [soldGb: number, chip: string, answer: string][] = [
|
||||
// The smallest need at 65536 tokens is 9.3 GB; the budget is 2.1 GB.
|
||||
// The smallest need at 65536 tokens is 10.2 GB; the budget is 2.1 GB.
|
||||
[8, 'Apple M2', 'none memory'],
|
||||
// The 9B build without a vision projector needs 9.3 GB at 65536 tokens, 0.898 of the 10.4 GB
|
||||
// estimate. The other two need 10.2 GB, 0.987: just out, until a runtime measures what a
|
||||
// real 16 GB Mac measured (see the Mac mini above). On an M1 (68 GB/s) a 9B is slow: 6.3.
|
||||
[16, 'Apple M4', 'limited ornith:9b-q4_k_m@65536 tight ok'],
|
||||
[16, 'Apple M1', 'limited ornith:9b-q4_k_m@65536 tight slow'],
|
||||
// The 9B needs 10.2 GB at 65536 tokens, 0.987 of the 10.4 GB estimate: just out. A runtime
|
||||
// that measured the budget a real 16 GB Mac measured admits it (see the Mac mini above).
|
||||
[16, 'Apple M4', 'none memory'],
|
||||
[16, 'Apple M1', 'none memory'],
|
||||
// 11.4 GB at 131072 tokens, 0.964 of 11.8 GB, and nothing of 12 billion fits: `limited`.
|
||||
[18, 'Apple M3 Pro', 'limited mimo-v2.6:9b-q4_k_m@131072 tight ok'],
|
||||
// On a base chip with 100 GB/s the 9B computes to 9.3 tokens per second: slow.
|
||||
[24, 'Apple M3', 'limited mimo-v2.6:9b-q4_k_m@131072 good slow'],
|
||||
[24, 'Apple M4', 'limited mimo-v2.6:9b-q4_k_m@131072 good ok'],
|
||||
[24, 'Apple M4 Pro', 'limited mimo-v2.6:9b-q4_k_m@131072 good ok'],
|
||||
// Muse Glimmer 30B fits at 65536 tokens: 21.2 GB, 0.970 of the 21.8 GB estimate. So a 32 GB
|
||||
// Mac is no small-model Mac (`ok`). The model is dense: on a base chip it computes to 3.5
|
||||
// tokens per second and the fast 9B stays the pick, on a Max chip (11.8) it is the pick.
|
||||
// North Mini Code needs 23.2 GB at 65536 tokens, 1.06 of the estimate: a runtime that
|
||||
// measures about 3/4 of the memory admits it (see below).
|
||||
[32, 'Apple M4', 'ok mimo-v2.6:9b-q4_k_m@131072 good ok'],
|
||||
[32, 'Apple M1 Max', 'ok muse-glimmer:30b-q4_k_m@65536 tight ok'],
|
||||
// North Mini Code needs 23.2 GB at 65536 tokens: 1.06 of the 21.8 GB estimate. A runtime
|
||||
// that measures about 3/4 of the memory admits it (see below).
|
||||
[32, 'Apple M4', 'limited mimo-v2.6:9b-q4_k_m@131072 good ok'],
|
||||
[32, 'Apple M1 Max', 'limited mimo-v2.6:9b-q4_k_m@131072 good ok'],
|
||||
// A Max chip runs the dense 27B fast enough (12.1); an M3 Max (300 GB/s) does not (8.9),
|
||||
// and takes the 35B-A3B as a GGUF build, whose memory does not grow after it loaded. Both
|
||||
// at 131072 tokens, 0.966 and 0.971 of the budget.
|
||||
@@ -896,25 +846,18 @@ describe('every memory size, with the stock catalogue', () => {
|
||||
expect(result.headline).toEqual({ text: LOCAL_MODEL_LABELS.noneMemory, vars: { memory: '8' } });
|
||||
// The smallest model a harness can work with needs more than four times the budget.
|
||||
const smallest = Math.min(...STOCK.models.map((m) => needBytes(m, 65536)));
|
||||
expect(formatGb(smallest)).toBe('9.3');
|
||||
expect(formatGb(smallest)).toBe('10.2');
|
||||
expect(smallest / result.budgetBytes).toBeGreaterThan(4);
|
||||
});
|
||||
|
||||
it('gives no model to 8 GB, a small model to every Mac from 16 to 24 GB, and every Mac from 36 GB a model of 27 billion or more', () => {
|
||||
it('gives no model to 8 and 16 GB, a small model to every Mac from 18 to 32 GB, and every larger one a model of 27 billion or more', () => {
|
||||
for (const hw of SWEEP) {
|
||||
const result = recommend(hw, STOCK, ALL_INSTALLED);
|
||||
const label = `${hw.memClassGb} GB ${hw.chip}`;
|
||||
if (hw.memClassGb <= 8) expect(result.reason, label).toBe('memory');
|
||||
else if (hw.memClassGb <= 24) {
|
||||
if (hw.memClassGb <= 16) expect(result.reason, label).toBe('memory');
|
||||
else if (hw.memClassGb <= 32) {
|
||||
expect(result.verdict, label).toBe('limited');
|
||||
expect(result.pick?.paramsB, label).toBe(9);
|
||||
} else if (hw.memClassGb === 32) {
|
||||
// A model of 30 billion fits, so the Mac is not `limited`. It is dense: the pick only
|
||||
// where it is not slow (a chip this file knows is fast, or one it knows nothing about).
|
||||
expect(result.verdict, label).toBe('ok');
|
||||
expect(rows(result).map((c) => c.modelId), label).toContain('muse-glimmer:30b-q4_k_m');
|
||||
expect(result.pick?.paramsB, label).toBe(hw.chip === 'Apple M4' ? 9 : 29.6);
|
||||
if (hw.chip === 'Apple M4') expect(result.notes[0], label).toEqual({ text: LOCAL_MODEL_LABELS.noteSmallPick });
|
||||
} else {
|
||||
expect(result.verdict, label).toBe('ok');
|
||||
expect(result.pick?.paramsB, label).toBeGreaterThanOrEqual(27);
|
||||
@@ -1677,13 +1620,11 @@ describe('the ranking', () => {
|
||||
expect(result.pick).toMatchObject({ context: 1024, fit: 'tight', budgetShare: 0.92 });
|
||||
});
|
||||
|
||||
it('lists each model once, never the pick again, and every model that fits', () => {
|
||||
// The bound is above what either catalogue holds, so nothing that fits is cut off.
|
||||
expect(MAX_ALTERNATIVES).toBe(11);
|
||||
for (const [, catalog] of BOTH) expect(catalog.models.length - 1).toBeLessThanOrEqual(MAX_ALTERNATIVES);
|
||||
// Seven models fit a 128 GB Mac: the pick and all six others are shown.
|
||||
it('lists each model once, never the pick again, and at most four alternatives', () => {
|
||||
expect(MAX_ALTERNATIVES).toBe(4);
|
||||
// Seven models fit a 128 GB Mac; the pick plus four are shown.
|
||||
const result = recommend(mac(128, 'Apple M5 Ultra'), RULES, ALL_INSTALLED);
|
||||
expect(result.alternatives).toHaveLength(6);
|
||||
expect(result.alternatives).toHaveLength(4);
|
||||
const ids = rows(result).map((c) => c.modelId);
|
||||
expect(new Set(ids).size).toBe(ids.length);
|
||||
for (const [, catalog] of BOTH) {
|
||||
@@ -1692,26 +1633,9 @@ describe('the ranking', () => {
|
||||
const sweptIds = rows(swept).map((c) => c.modelId);
|
||||
expect(new Set(sweptIds).size).toBe(sweptIds.length);
|
||||
expect(swept.alternatives.length).toBeLessThanOrEqual(MAX_ALTERNATIVES);
|
||||
// Every model that fits at a context a harness works at is on the list (the sweep's
|
||||
// Macs have 500 GB of free disk, so none is left out for room).
|
||||
const fitting = catalog.models.filter((m) =>
|
||||
m.contexts.some((ctx) => ctx >= 65536 && modelFitsBudget(hw, catalog, m.id, ctx))
|
||||
);
|
||||
expect([...sweptIds].sort(), `${hw.memClassGb} GB ${hw.chip}`).toEqual(fitting.map((m) => m.id).sort());
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('still cuts a list longer than the bound, and the install can only take what is listed', () => {
|
||||
// Fourteen models that all fit: the pick and MAX_ALTERNATIVES others, best rank first.
|
||||
const many = catalogOf(
|
||||
Array.from({ length: 14 }, (_, index) => modelNeeding(`m${index + 1}:x`, GIB, { rank: index + 1 }))
|
||||
);
|
||||
const result = recommend(HUNDRED_GIB_MAC, many, [fact('alpha')]);
|
||||
expect(rows(result)).toHaveLength(1 + MAX_ALTERNATIVES);
|
||||
expect(rows(result)[0].modelId).toBe('m14:x');
|
||||
expect(rows(result).at(-1)?.modelId).toBe('m3:x');
|
||||
});
|
||||
});
|
||||
|
||||
describe('harnesses', () => {
|
||||
@@ -2140,8 +2064,6 @@ describe('what each row of the chooser carries', () => {
|
||||
'qwen3.5:9b-q8_0': 'estimated',
|
||||
'qwen3.5:9b': 'estimated',
|
||||
'gpt-oss:20b': 'estimated',
|
||||
// Slow on this chip, so it was the sixth row: listed since the list holds every model.
|
||||
'qwen3.8:27b-q8_0': 'estimated',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -2153,19 +2075,10 @@ describe('the measured budget, end to end', () => {
|
||||
const measured = recommend(hw, STOCK, ALL_INSTALLED, { measuredTotalBytes: MACBOOK_MEASURED_TOTAL });
|
||||
expect([estimated.budgetSource, estimated.budgetGb]).toEqual(['estimated', '37.6']);
|
||||
expect([measured.budgetSource, measured.budgetGb]).toEqual(['measured', '40.2']);
|
||||
// The same models in the same order. The share of each changes, and one model steps up a
|
||||
// context: the 27B in 8 bits fits 65536 tokens under the estimate (0.975) and 131072 under
|
||||
// what the runtime reported (0.976).
|
||||
expect(rows(measured).map((c) => c.modelId)).toEqual(rows(estimated).map((c) => c.modelId));
|
||||
const stepped = order(estimated).filter((entry, index) => order(measured)[index] !== entry);
|
||||
expect(stepped).toEqual(['qwen3.8:27b-q8_0@65536']);
|
||||
expect(order(measured)).toContain('qwen3.8:27b-q8_0@131072');
|
||||
expect(rows(estimated).map((c) => c.budgetShare)).toEqual([
|
||||
0.957, 0.767, 0.692, 0.303, 0.278, 0.303, 0.853, 0.975, 0.576,
|
||||
]);
|
||||
expect(rows(measured).map((c) => c.budgetShare)).toEqual([
|
||||
0.895, 0.717, 0.647, 0.283, 0.26, 0.283, 0.798, 0.976, 0.539,
|
||||
]);
|
||||
// The same rows in the same order; only the share of each changes.
|
||||
expect(order(measured)).toEqual(order(estimated));
|
||||
expect(rows(estimated).map((c) => c.budgetShare)).toEqual([0.957, 0.767, 0.692, 0.303, 0.303]);
|
||||
expect(rows(measured).map((c) => c.budgetShare)).toEqual([0.895, 0.717, 0.647, 0.283, 0.283]);
|
||||
for (const candidate of rows(measured)) {
|
||||
expect(candidate.budgetShare).toBeCloseTo(candidate.needBytes / MACBOOK_MEASURED_TOTAL, 3);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,309 @@
|
||||
/**
|
||||
* @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,7 +302,13 @@ describe('POST /api/cases/:name/rename (a linked case)', () => {
|
||||
const real = join(work, 'my-project');
|
||||
mkdirSync(real);
|
||||
writeFileSync(LINKED(), JSON.stringify({ proj: real }));
|
||||
writeFileSync(SETTINGS_PATH, JSON.stringify({ caseOrder: ['a', 'proj', 'b'], lastUsedCase: 'proj' }));
|
||||
const seeded = {
|
||||
caseOrder: ['a', 'proj', 'b'],
|
||||
lastUsedCase: 'proj',
|
||||
lastPickedCase: 'proj',
|
||||
caseSettings: { proj: { agentTeams: true, opusContext1m: false } },
|
||||
};
|
||||
writeFileSync(SETTINGS_PATH, JSON.stringify(seeded));
|
||||
// the registry reads fine but cannot be written: the rename must undo the folder move
|
||||
const spy = failRegistryWrite();
|
||||
let res;
|
||||
@@ -316,7 +322,7 @@ describe('POST /api/cases/:name/rename (a linked case)', () => {
|
||||
expect(linked()).toEqual({ proj: real });
|
||||
expect(existsSync(join(work, 'renamed'))).toBe(false);
|
||||
// settings.json is written first, so it must be put back too
|
||||
expect(settings()).toEqual({ caseOrder: ['a', 'proj', 'b'], lastUsedCase: 'proj' });
|
||||
expect(settings()).toEqual(seeded);
|
||||
});
|
||||
|
||||
it('puts back only the keys it wrote, keeping a settings write made in between', async () => {
|
||||
@@ -324,17 +330,38 @@ describe('POST /api/cases/:name/rename (a linked case)', () => {
|
||||
const real = join(work, 'my-project');
|
||||
mkdirSync(real);
|
||||
writeFileSync(LINKED(), JSON.stringify({ proj: real }));
|
||||
writeFileSync(SETTINGS_PATH, JSON.stringify({ caseOrder: ['a', 'proj', 'b'], lastUsedCase: 'proj' }));
|
||||
// Another writer (a settings save) lands after the rename wrote settings.json and before the
|
||||
// registry write fails.
|
||||
const spy = failRegistryWrite(() => mutateSettingsFile((current) => ({ ...current, agentSkillEnabled: true })));
|
||||
const entry = { agentTeams: true, opusContext1m: false };
|
||||
writeFileSync(
|
||||
SETTINGS_PATH,
|
||||
JSON.stringify({
|
||||
caseOrder: ['a', 'proj', 'b'],
|
||||
lastUsedCase: 'proj',
|
||||
lastPickedCase: 'proj',
|
||||
caseSettings: { proj: entry },
|
||||
})
|
||||
);
|
||||
// Another writer (a settings save, and a per-case save from another device) lands after the rename wrote
|
||||
// settings.json and before the registry write fails.
|
||||
const spy = failRegistryWrite(() =>
|
||||
mutateSettingsFile((current) => ({
|
||||
...current,
|
||||
agentSkillEnabled: true,
|
||||
caseSettings: { ...(current.caseSettings as object), other: { agentTeams: false, opusContext1m: true } },
|
||||
}))
|
||||
);
|
||||
try {
|
||||
const res = await rename(app, 'proj', { newName: 'renamed', renameFolder: true });
|
||||
expect(res.statusCode).toBe(500);
|
||||
} finally {
|
||||
spy.mockRestore();
|
||||
}
|
||||
expect(settings()).toEqual({ caseOrder: ['a', 'proj', 'b'], lastUsedCase: 'proj', agentSkillEnabled: true });
|
||||
expect(settings()).toEqual({
|
||||
caseOrder: ['a', 'proj', 'b'],
|
||||
lastUsedCase: 'proj',
|
||||
lastPickedCase: 'proj',
|
||||
caseSettings: { proj: entry, other: { agentTeams: false, opusContext1m: true } },
|
||||
agentSkillEnabled: true,
|
||||
});
|
||||
});
|
||||
|
||||
describe('when the registry changes after the first check', () => {
|
||||
|
||||
@@ -0,0 +1,322 @@
|
||||
/**
|
||||
* @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();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 = { _getDeepgramConfig: () => ({}), _saveDeepgramConfig() {}, refreshClaudeStatus: () => Promise.resolve() };
|
||||
var VoiceInput = { SILENCE_TIMEOUT_SECONDS: [0, 3, 6, 15, 30], _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 = { _getDeepgramConfig: () => ({}), _saveDeepgramConfig() {}, refreshClaudeStatus: () => Promise.resolve() };
|
||||
var VoiceInput = { SILENCE_TIMEOUT_SECONDS: [0, 3, 6, 15, 30], _getDeepgramConfig: () => ({}), _saveDeepgramConfig() {}, refreshClaudeStatus: () => Promise.resolve() };
|
||||
var FocusTrap = function () { this.activate = () => {}; this.deactivate = () => {}; };
|
||||
var DEFAULT_VOICE_KEYTERMS = '';`,
|
||||
read('constants.js'),
|
||||
@@ -526,6 +526,10 @@ describe('the new toasts in zh-CN', () => {
|
||||
'Saved on this device, not on the server: the server could not be reached',
|
||||
'已保存到本设备,但未保存到服务器:无法连接到服务器',
|
||||
],
|
||||
[
|
||||
'Options for "demo" (until the next sync) saved on this device, not on the server: HTTP 500',
|
||||
'案例“demo”的选项已保存到本设备(同步前有效),但未保存到服务器:HTTP 500',
|
||||
],
|
||||
[`Tunnel not stopped: ${TORN}`, `隧道未停止:${TORN}`],
|
||||
];
|
||||
for (const [english, chinese] of cases) {
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* @fileoverview Opt-in case sync, server side (live server, ephemeral port, real settings.json): the shared
|
||||
* per-case options merge inside the serialised settings writer, a non-admin never reads them, and a case
|
||||
* rename or delete carries them along (CASE-SYNC-001, CASE-SYNC-002).
|
||||
*/
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { WebServer } from '../src/web/server.js';
|
||||
import { TmuxManager } from '../src/tmux-manager.js';
|
||||
import { SETTINGS_PATH } from '../src/web/route-helpers.js';
|
||||
import { getCasesDir } from '../src/config/cases-dir.js';
|
||||
import { settingsForNonAdmin } from '../src/web/routes/system-routes.js';
|
||||
import {
|
||||
dropCaseSettings,
|
||||
MAX_SHARED_CASES,
|
||||
mergeCaseSettings,
|
||||
renameCaseSettings,
|
||||
} from '../src/web/case-state-sync.js';
|
||||
|
||||
vi.spyOn(TmuxManager, 'isTmuxAvailable').mockReturnValue(true);
|
||||
|
||||
const on = { agentTeams: true, opusContext1m: false };
|
||||
const off = { agentTeams: false, opusContext1m: true };
|
||||
|
||||
let server: WebServer;
|
||||
const url = (p: string) => `http://localhost:${server.boundPort}${p}`;
|
||||
const seed = (value: unknown) => writeFileSync(SETTINGS_PATH, JSON.stringify(value, null, 2));
|
||||
const readSettings = () => JSON.parse(readFileSync(SETTINGS_PATH, 'utf-8')) as Record<string, any>;
|
||||
async function send(method: string, path: string, body?: unknown) {
|
||||
const res = await fetch(url(path), {
|
||||
method,
|
||||
headers: body === undefined ? {} : { 'Content-Type': 'application/json' },
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
});
|
||||
return { status: res.status, body: (await res.json()) as Record<string, any> };
|
||||
}
|
||||
|
||||
let n = 0;
|
||||
const caseName = () => `case-sync-${process.pid}-${++n}`;
|
||||
|
||||
beforeAll(async () => {
|
||||
mkdirSync(join(SETTINGS_PATH, '..'), { recursive: true });
|
||||
seed({});
|
||||
server = new WebServer(0, false, true);
|
||||
await server.start();
|
||||
}, 30000);
|
||||
|
||||
afterAll(async () => {
|
||||
await server?.stop();
|
||||
vi.restoreAllMocks();
|
||||
}, 30000);
|
||||
|
||||
describe('shared case options', () => {
|
||||
beforeEach(() => seed({ syncCaseState: true }));
|
||||
|
||||
it('merges concurrent saves of different cases instead of letting the second discard the first', async () => {
|
||||
const [a, b, c] = await Promise.all([
|
||||
send('PUT', '/api/settings', { caseSettings: { alpha: on } }),
|
||||
send('PUT', '/api/settings', { caseSettings: { beta: off } }),
|
||||
send('PUT', '/api/settings', { caseSettings: { gamma: on } }),
|
||||
]);
|
||||
expect([a.status, b.status, c.status]).toEqual([200, 200, 200]);
|
||||
expect(readSettings().caseSettings).toEqual({ alpha: on, beta: off, gamma: on });
|
||||
});
|
||||
|
||||
it('ignores a caseSettings PUT while syncCaseState is off, and takes one that turns it on', async () => {
|
||||
seed({ syncCaseState: false, caseSettings: { kept: off } });
|
||||
expect((await send('PUT', '/api/settings', { caseSettings: { alpha: on } })).status).toBe(200);
|
||||
expect(readSettings().caseSettings).toEqual({ kept: off });
|
||||
seed({});
|
||||
await send('PUT', '/api/settings', { caseSettings: { alpha: on } });
|
||||
expect(readSettings().caseSettings).toBeUndefined();
|
||||
await send('PUT', '/api/settings', { syncCaseState: true, caseSettings: { alpha: on } });
|
||||
expect(readSettings().caseSettings).toEqual({ alpha: on });
|
||||
});
|
||||
|
||||
it('keeps the other cases when one is saved again, and every other setting', async () => {
|
||||
seed({ syncCaseState: true, lastUsedCase: 'x', caseSettings: { alpha: on, beta: off } });
|
||||
await send('PUT', '/api/settings', { caseSettings: { alpha: off } });
|
||||
expect(readSettings()).toEqual({ syncCaseState: true, lastUsedCase: 'x', caseSettings: { alpha: off, beta: off } });
|
||||
});
|
||||
|
||||
it('refuses a merge that takes the file past the limit and leaves it as it was', async () => {
|
||||
const full = Object.fromEntries(Array.from({ length: MAX_SHARED_CASES }, (_, i) => [`c${i}`, on]));
|
||||
seed({ syncCaseState: true, caseSettings: full });
|
||||
const res = await send('PUT', '/api/settings', { caseSettings: { overflow: on } });
|
||||
expect(res.status).toBe(400);
|
||||
expect(Object.keys(readSettings().caseSettings)).toHaveLength(MAX_SHARED_CASES);
|
||||
expect((await send('PUT', '/api/settings', { caseSettings: { c1: off } })).status).toBe(200);
|
||||
});
|
||||
|
||||
it('moves the options with a renamed case, and drops them when the case is deleted', async () => {
|
||||
const oldName = caseName();
|
||||
const newName = caseName();
|
||||
mkdirSync(join(getCasesDir(), oldName), { recursive: true });
|
||||
seed({
|
||||
syncCaseState: true,
|
||||
caseOrder: [oldName],
|
||||
lastPickedCase: oldName,
|
||||
caseSettings: { [oldName]: on, keep: off },
|
||||
});
|
||||
const renamed = await send('POST', `/api/cases/${oldName}/rename`, { newName });
|
||||
expect(renamed.body.success).toBe(true);
|
||||
expect(readSettings().caseSettings).toEqual({ [newName]: on, keep: off });
|
||||
expect(readSettings().lastPickedCase).toBe(newName);
|
||||
const deleted = await send('DELETE', `/api/cases/${newName}`);
|
||||
expect(deleted.body.success).toBe(true);
|
||||
expect(readSettings().caseSettings).toEqual({ keep: off });
|
||||
});
|
||||
});
|
||||
|
||||
describe('what a non-admin is given', () => {
|
||||
it('has the shared map removed, and keeps the switch the page reads', () => {
|
||||
const out = settingsForNonAdmin({
|
||||
syncCaseState: true,
|
||||
caseOrder: ['a'],
|
||||
lastUsedCase: 'a',
|
||||
lastPickedCase: 'a',
|
||||
caseSettings: { a: on },
|
||||
agentSkillEnabled: true,
|
||||
});
|
||||
expect(out).toEqual({ syncCaseState: true, agentSkillEnabled: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('case map helpers', () => {
|
||||
it('merge, rename and drop leave the input alone and report nothing to do as null', () => {
|
||||
const map = { a: on, b: off };
|
||||
expect(mergeCaseSettings(map, { c: on })).toEqual({ a: on, b: off, c: on });
|
||||
expect(renameCaseSettings(map, 'a', 'z')).toEqual({ b: off, z: on });
|
||||
expect(renameCaseSettings(map, 'nope', 'z')).toBeNull();
|
||||
expect(dropCaseSettings(map, 'a')).toEqual({ b: off });
|
||||
expect(dropCaseSettings(map, 'nope')).toBeNull();
|
||||
expect(mergeCaseSettings(undefined, { a: on })).toEqual({ a: on });
|
||||
expect(map).toEqual({ a: on, b: off });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* @fileoverview Case sync across two devices, end to end: a real server on an ephemeral port (isolated test
|
||||
* HOME) and two separate Chromium contexts (each with its own localStorage, like two devices). Saves of
|
||||
* different cases from both contexts both land, the other context takes them on returning to its tab, and
|
||||
* once the server says sync is off the selected case stops following.
|
||||
*/
|
||||
import { mkdirSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { chromium, type Browser, type Page } from 'playwright';
|
||||
import { WebServer } from '../src/web/server.js';
|
||||
import { getCasesDir } from '../src/config/cases-dir.js';
|
||||
|
||||
describe('Sync cases across devices in two browsers', () => {
|
||||
let server: WebServer;
|
||||
let browser: Browser;
|
||||
let a: Page;
|
||||
let b: Page;
|
||||
const one = `sync-a-${process.pid}`;
|
||||
const two = `sync-b-${process.pid}`;
|
||||
const base = () => `http://localhost:${server.boundPort}`;
|
||||
const put = (body: unknown) =>
|
||||
fetch(`${base()}/api/settings`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const serverSettings = async () => ((await (await fetch(`${base()}/api/settings`)).json()) as any).data;
|
||||
const open = async () => {
|
||||
const page = await (await browser.newContext({ viewport: { width: 1400, height: 900 } })).newPage();
|
||||
await page.goto(base(), { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForFunction(() => (window as any).app?.terminal, null, { timeout: 30000 });
|
||||
return page;
|
||||
};
|
||||
const opts = (page: Page, name: string) => page.evaluate((n) => (window as any).app.getCaseSettings(n), name);
|
||||
const returnToTab = (page: Page) =>
|
||||
page.evaluate(async () => {
|
||||
const app = (window as any).app;
|
||||
await app._syncCaseStateFromServer();
|
||||
});
|
||||
|
||||
beforeAll(async () => {
|
||||
for (const name of [one, two]) mkdirSync(join(getCasesDir(), name), { recursive: true });
|
||||
server = new WebServer(0, false, true);
|
||||
await server.start();
|
||||
browser = await chromium.launch({ headless: true });
|
||||
await put({ syncCaseState: true });
|
||||
a = await open();
|
||||
b = await open();
|
||||
for (const page of [a, b]) {
|
||||
await page.evaluate(async () => {
|
||||
const app = (window as any).app;
|
||||
await app.loadAppSettingsFromServer();
|
||||
});
|
||||
}
|
||||
}, 60000);
|
||||
|
||||
afterAll(async () => {
|
||||
await browser?.close();
|
||||
await server?.stop();
|
||||
}, 60000);
|
||||
|
||||
it("keeps both devices' saves and lets the other device take them", async () => {
|
||||
await Promise.all([
|
||||
a.evaluate((n) => (window as any).app.saveCaseSettings(n, { agentTeams: true, opusContext1m: false }), one),
|
||||
b.evaluate((n) => (window as any).app.saveCaseSettings(n, { agentTeams: false, opusContext1m: false }), two),
|
||||
]);
|
||||
await expect
|
||||
.poll(async () => Object.keys((await serverSettings()).caseSettings ?? {}).sort(), { timeout: 10000 })
|
||||
.toEqual([one, two].sort());
|
||||
await returnToTab(b);
|
||||
await returnToTab(a);
|
||||
expect(await opts(b, one)).toEqual({ agentTeams: true, opusContext1m: false });
|
||||
expect(await opts(a, two)).toEqual({ agentTeams: false, opusContext1m: false });
|
||||
});
|
||||
|
||||
it('follows the selected case while sync is on, and stops once the server says it is off', async () => {
|
||||
await put({ lastPickedCase: two });
|
||||
await returnToTab(a);
|
||||
expect(await a.evaluate(() => (document.getElementById('quickStartCase') as HTMLSelectElement).value)).toBe(two);
|
||||
|
||||
await put({ syncCaseState: false, lastPickedCase: one });
|
||||
await returnToTab(a);
|
||||
expect(await a.evaluate(() => (document.getElementById('quickStartCase') as HTMLSelectElement).value)).toBe(two);
|
||||
expect(await a.evaluate(() => (window as any).app.loadAppSettingsFromStorage().syncCaseState)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,331 @@
|
||||
/**
|
||||
* @fileoverview Opt-in case sync, client side: the schema accepts the setting and a per-case map, and the
|
||||
* session-ui helpers only touch the server when the setting is on, send one case at a time, report a failed
|
||||
* save, and obey the server's answer about whether sync is on. Evaluates the real session-ui.js.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import vm from 'node:vm';
|
||||
import { SettingsUpdateSchema } from '../src/web/schemas.js';
|
||||
|
||||
const SRC = readFileSync(new URL('../src/web/public/session-ui.js', import.meta.url), 'utf-8');
|
||||
// The real write-result handling the case sync reuses (a toast for a failed save, read-only after a 403).
|
||||
const SETTINGS_SRC = readFileSync(new URL('../src/web/public/settings-ui.js', import.meta.url), 'utf-8');
|
||||
|
||||
type Reply = { status?: number; body?: unknown } | 'throw';
|
||||
type Handler = (url: string, init?: { method?: string; body?: string }) => Reply | Promise<Reply>;
|
||||
|
||||
function makeApp(settings: Record<string, unknown>, onFetch: Handler = () => ({ body: { success: true, data: {} } })) {
|
||||
const store = new Map<string, string>();
|
||||
const calls: { method: string; body: unknown }[] = [];
|
||||
const toasts: string[] = [];
|
||||
const select = { value: 'alpha' };
|
||||
const hidden = new Set<string>();
|
||||
const popover = (id: string) => ({ classList: { add: (c: string) => c === 'hidden' && void hidden.add(id) } });
|
||||
function CodemanApp(this: unknown) {}
|
||||
const ctx = vm.createContext({
|
||||
CodemanApp,
|
||||
window: {},
|
||||
document: {
|
||||
getElementById: (id: string) => (id.startsWith('caseSettingsPopover') ? popover(id) : select),
|
||||
addEventListener: () => {},
|
||||
},
|
||||
localStorage: {
|
||||
getItem: (k: string) => store.get(k) ?? null,
|
||||
setItem: (k: string, v: string) => void store.set(k, v),
|
||||
},
|
||||
fetch: async (url: string, init?: { method?: string; body?: string }) => {
|
||||
calls.push({ method: init?.method ?? 'GET', body: init?.body ? JSON.parse(init.body) : undefined });
|
||||
const r = await onFetch(url, init);
|
||||
if (r === 'throw') throw new Error('offline');
|
||||
const status = r.status ?? 200;
|
||||
const res = { ok: status < 400, status, json: async () => r.body ?? {}, clone: () => res };
|
||||
return res;
|
||||
},
|
||||
console,
|
||||
});
|
||||
vm.runInContext(SRC, ctx);
|
||||
vm.runInContext(SETTINGS_SRC, ctx);
|
||||
const app = Object.create((CodemanApp as unknown as { prototype: object }).prototype) as Record<string, any>;
|
||||
app.loadAppSettingsFromStorage = () => settings;
|
||||
app.saveAppSettingsToStorage = (s: Record<string, unknown>) => Object.assign(settings, s);
|
||||
app.showToast = (m: string) => void toasts.push(m);
|
||||
app.cases = [{ name: 'alpha' }, { name: 'beta' }];
|
||||
app.updateDirDisplayForCase = () => {};
|
||||
app.updateMobileCaseLabel = () => {};
|
||||
app.updateCasePickerInput = () => {};
|
||||
app.closeCasePicker = () => {};
|
||||
return { app, store, calls, toasts, select, settings, hidden };
|
||||
}
|
||||
|
||||
const settle = () => new Promise((r) => setTimeout(r, 0));
|
||||
const entry = { agentTeams: true, opusContext1m: false };
|
||||
|
||||
describe('syncCaseState schema', () => {
|
||||
it('accepts the setting with a per-case map, and rejects junk', () => {
|
||||
expect(SettingsUpdateSchema.safeParse({ syncCaseState: true, caseSettings: { demo: entry } }).success).toBe(true);
|
||||
expect(SettingsUpdateSchema.safeParse({ caseSettings: { demo: { agentTeams: 'yes' } } }).success).toBe(false);
|
||||
expect(
|
||||
SettingsUpdateSchema.safeParse({ caseSettings: { demo: { agentTeams: true, opusContext1m: true, x: 1 } } })
|
||||
.success
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('saving a case', () => {
|
||||
it('stays on this browser while the setting is off', async () => {
|
||||
const { app, store, calls } = makeApp({});
|
||||
app.saveCaseSettings('demo', entry);
|
||||
await settle();
|
||||
expect(store.get('caseSettings_demo')).toContain('agentTeams');
|
||||
expect(calls).toEqual([]);
|
||||
});
|
||||
|
||||
it('sends only that case (no read of the server map first), and nothing when read-only', async () => {
|
||||
const on = makeApp({ syncCaseState: true });
|
||||
on.app.saveCaseSettings('demo', entry);
|
||||
await settle();
|
||||
expect(on.calls).toEqual([{ method: 'PUT', body: { caseSettings: { demo: entry } } }]);
|
||||
const ro = makeApp({ syncCaseState: true });
|
||||
ro.app._settingsReadOnly = () => true;
|
||||
ro.app.saveCaseSettings('demo', entry);
|
||||
await settle();
|
||||
expect(ro.calls).toEqual([]);
|
||||
});
|
||||
|
||||
it('sends rapid saves one after another, in the order they were made', async () => {
|
||||
let release!: () => void;
|
||||
const first = new Promise<void>((r) => (release = r));
|
||||
let n = 0;
|
||||
const { app, calls } = makeApp({ syncCaseState: true }, async () => {
|
||||
if (++n === 1) await first;
|
||||
return { body: { success: true, data: {} } };
|
||||
});
|
||||
app.saveCaseSettings('demo', { agentTeams: true, opusContext1m: true });
|
||||
app.saveCaseSettings('demo', { agentTeams: false, opusContext1m: false });
|
||||
await settle();
|
||||
expect(calls).toHaveLength(1); // the second waits for the first
|
||||
release();
|
||||
await app._caseSyncChain;
|
||||
expect(calls.map((c) => (c.body as any).caseSettings.demo.agentTeams)).toEqual([true, false]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['an HTTP 500', { status: 500, body: { success: false, error: 'boom' } }],
|
||||
['a dropped connection', 'throw' as const],
|
||||
])('keeps the local choice and says it was not synced after %s', async (_label, reply) => {
|
||||
const { app, store, toasts } = makeApp({ syncCaseState: true }, () => reply as Reply);
|
||||
app.saveCaseSettings('demo', entry);
|
||||
await app._caseSyncChain;
|
||||
expect(JSON.parse(store.get('caseSettings_demo')!)).toEqual(entry);
|
||||
expect(toasts).toHaveLength(1);
|
||||
expect(toasts[0]).toContain('not on the server');
|
||||
expect(toasts[0]).toContain('until the next sync');
|
||||
});
|
||||
|
||||
it('hands an admin-only refusal to the read-only handling, without a toast', async () => {
|
||||
const forbidden = { status: 403, body: { success: false, errorCode: 'FORBIDDEN' } };
|
||||
const { app, toasts } = makeApp({ syncCaseState: true }, () => forbidden);
|
||||
app._noteSettingsWriteRefusal = async (res: { status: number }) => {
|
||||
if (res.status !== 403) return false;
|
||||
app._settingsWriteForbidden = true;
|
||||
return true;
|
||||
};
|
||||
app._settingsReadOnly = () => app._settingsWriteForbidden === true;
|
||||
app.saveCaseSettings('demo', entry);
|
||||
await app._caseSyncChain;
|
||||
expect(toasts).toEqual([]);
|
||||
expect(app._caseStateSyncOn()).toBe(false); // nothing more is sent from this page
|
||||
});
|
||||
});
|
||||
|
||||
describe('recording the picked case', () => {
|
||||
it('writes lastPickedCase with lastUsedCase only while sync is on', async () => {
|
||||
const on = makeApp({ syncCaseState: true });
|
||||
await on.app.saveLastUsedCase('beta');
|
||||
expect(on.calls[0].body).toEqual({ lastUsedCase: 'beta', lastPickedCase: 'beta' });
|
||||
const off = makeApp({});
|
||||
await off.app.saveLastUsedCase('beta');
|
||||
expect(off.calls[0].body).toEqual({ lastUsedCase: 'beta' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('a pick that did not reach the server', () => {
|
||||
it('is not undone by the next tab return, and following resumes once a pick is stored', async () => {
|
||||
let putOk = false;
|
||||
const { app, select } = makeApp({ syncCaseState: true }, (_url, init) =>
|
||||
init?.method === 'PUT'
|
||||
? putOk
|
||||
? { body: { success: true } }
|
||||
: { status: 500, body: { error: 'x' } }
|
||||
: { body: { success: true, data: { syncCaseState: true, lastPickedCase: 'alpha' } } }
|
||||
);
|
||||
select.value = 'beta';
|
||||
await app.saveLastUsedCase('beta'); // the pick fails
|
||||
await app._syncCaseStateFromServer(); // the server still says alpha
|
||||
expect(select.value).toBe('beta');
|
||||
putOk = true;
|
||||
await app.saveLastUsedCase('beta');
|
||||
await app._syncCaseStateFromServer();
|
||||
expect(select.value).toBe('alpha');
|
||||
});
|
||||
|
||||
it('lands before the copy a tab return reads', async () => {
|
||||
const order: string[] = [];
|
||||
const { app } = makeApp({ syncCaseState: true }, async (_url, init) => {
|
||||
order.push(init?.method ?? 'GET');
|
||||
return { body: { success: true, data: { syncCaseState: true } } };
|
||||
});
|
||||
void app.saveLastUsedCase('beta');
|
||||
await app._syncCaseStateFromServer();
|
||||
expect(order).toEqual(['PUT', 'GET']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('taking the server copy', () => {
|
||||
const server = { syncCaseState: true, caseSettings: { demo: entry } };
|
||||
|
||||
it('writes the options into the local keys only while the server says sync is on', () => {
|
||||
const a = makeApp({});
|
||||
a.app._applyServerCaseSettings(server);
|
||||
expect(JSON.parse(a.store.get('caseSettings_demo')!)).toEqual(entry);
|
||||
const b = makeApp({});
|
||||
b.app._applyServerCaseSettings({ ...server, syncCaseState: false });
|
||||
expect(b.store.size).toBe(0);
|
||||
});
|
||||
|
||||
it('never applies a map to a read-only identity, even one the server handed over', () => {
|
||||
const { app, store } = makeApp({ syncCaseState: true });
|
||||
app._settingsReadOnly = () => true;
|
||||
app._applyServerCaseSettings(server);
|
||||
expect(store.size).toBe(0);
|
||||
});
|
||||
|
||||
it('leaves a case alone while this device is still saving it', async () => {
|
||||
let release!: () => void;
|
||||
const hold = new Promise<void>((r) => (release = r));
|
||||
const { app, store } = makeApp({ syncCaseState: true }, async () => {
|
||||
await hold;
|
||||
return { body: { success: true, data: {} } };
|
||||
});
|
||||
app.saveCaseSettings('demo', { agentTeams: false, opusContext1m: false });
|
||||
app._applyServerCaseSettings({ syncCaseState: true, caseSettings: { demo: entry, other: entry } });
|
||||
expect(JSON.parse(store.get('caseSettings_demo')!)).toEqual({ agentTeams: false, opusContext1m: false });
|
||||
expect(JSON.parse(store.get('caseSettings_other')!)).toEqual(entry);
|
||||
release();
|
||||
await app._caseSyncChain;
|
||||
});
|
||||
});
|
||||
|
||||
describe('returning to the tab', () => {
|
||||
const env = (data: Record<string, unknown>) => ({ body: { success: true, data } });
|
||||
|
||||
it('follows the selected case and the options while sync is on', async () => {
|
||||
const { app, select, store } = makeApp({ syncCaseState: true }, () =>
|
||||
env({ syncCaseState: true, lastPickedCase: 'beta', caseSettings: { beta: entry } })
|
||||
);
|
||||
await app._syncCaseStateFromServer();
|
||||
expect(select.value).toBe('beta');
|
||||
expect(JSON.parse(store.get('caseSettings_beta')!)).toEqual(entry);
|
||||
});
|
||||
|
||||
it('stops following when another device turned sync off, and the cached switch follows the server', async () => {
|
||||
const { app, select, store, settings } = makeApp({ syncCaseState: true }, () =>
|
||||
env({ syncCaseState: false, lastPickedCase: 'beta', caseSettings: { beta: entry } })
|
||||
);
|
||||
await app._syncCaseStateFromServer();
|
||||
expect(select.value).toBe('alpha');
|
||||
expect(store.size).toBe(0);
|
||||
expect(settings.syncCaseState).toBe(false);
|
||||
expect(app._caseStateSyncOn()).toBe(false);
|
||||
});
|
||||
|
||||
it('does not follow a case a quick-start or an agent started, only one picked in a picker', async () => {
|
||||
const { app, select } = makeApp({ syncCaseState: true }, () =>
|
||||
env({ syncCaseState: true, lastUsedCase: 'beta', lastPickedCase: 'alpha' })
|
||||
);
|
||||
await app._syncCaseStateFromServer();
|
||||
expect(select.value).toBe('alpha');
|
||||
});
|
||||
|
||||
it('closes an open case-options popover when the follow or an import changes what it shows', async () => {
|
||||
const followed = makeApp({ syncCaseState: true }, () => env({ syncCaseState: true, lastPickedCase: 'beta' }));
|
||||
await followed.app._syncCaseStateFromServer();
|
||||
expect([...followed.hidden].sort()).toEqual(['caseSettingsPopover', 'caseSettingsPopoverMobile']);
|
||||
const imported = makeApp({ syncCaseState: true }, () =>
|
||||
env({ syncCaseState: true, caseSettings: { alpha: entry } })
|
||||
);
|
||||
await imported.app._syncCaseStateFromServer();
|
||||
expect(imported.hidden.size).toBe(2);
|
||||
const unchanged = makeApp({ syncCaseState: true }, () => env({ syncCaseState: true, lastPickedCase: 'alpha' }));
|
||||
await unchanged.app._syncCaseStateFromServer();
|
||||
expect(unchanged.hidden.size).toBe(0);
|
||||
});
|
||||
|
||||
it('does not undo a pick or a case save made while its read was out', async () => {
|
||||
let release!: () => void;
|
||||
const gate = new Promise<void>((r) => (release = r));
|
||||
const { app, select, store } = makeApp({ syncCaseState: true }, async (_url, init) => {
|
||||
if (init?.method === 'PUT') return { body: { success: true, data: {} } };
|
||||
await gate; // the server read this copy before the pick and the save below reached it
|
||||
return env({
|
||||
syncCaseState: true,
|
||||
lastPickedCase: 'alpha',
|
||||
caseSettings: { beta: { agentTeams: false, opusContext1m: true } },
|
||||
});
|
||||
});
|
||||
const refresh = app._syncCaseStateFromServer();
|
||||
await settle();
|
||||
select.value = 'beta';
|
||||
await app.saveLastUsedCase('beta');
|
||||
app.saveCaseSettings('beta', entry);
|
||||
await app._caseSyncChain;
|
||||
release();
|
||||
await refresh;
|
||||
expect(select.value).toBe('beta');
|
||||
expect(JSON.parse(store.get('caseSettings_beta')!)).toEqual(entry);
|
||||
});
|
||||
|
||||
it('asks for nothing on a tab return while this device has sync off', async () => {
|
||||
const { app, select, calls } = makeApp({}, () => env({ syncCaseState: true, lastUsedCase: 'beta' }));
|
||||
await app._syncCaseStateFromServer();
|
||||
expect(calls).toEqual([]);
|
||||
expect(select.value).toBe('alpha');
|
||||
});
|
||||
|
||||
it('does nothing for a read-only identity', async () => {
|
||||
const { app, calls } = makeApp({ syncCaseState: true });
|
||||
app._settingsReadOnly = () => true;
|
||||
await app._syncCaseStateFromServer();
|
||||
expect(calls).toEqual([]);
|
||||
});
|
||||
|
||||
it('keeps what it has when the request fails', async () => {
|
||||
const { app, select } = makeApp({ syncCaseState: true }, () => ({ status: 500, body: { error: 'x' } }));
|
||||
await app._syncCaseStateFromServer();
|
||||
expect(select.value).toBe('alpha');
|
||||
});
|
||||
|
||||
it('discards an older refresh that answers after a newer one', async () => {
|
||||
const releases: (() => void)[] = [];
|
||||
const answers = [
|
||||
env({ syncCaseState: true, lastPickedCase: 'beta' }), // the older request
|
||||
env({ syncCaseState: true, lastPickedCase: 'alpha' }), // the newer request
|
||||
];
|
||||
let i = 0;
|
||||
const { app, select } = makeApp({ syncCaseState: true }, async () => {
|
||||
const mine = answers[i++];
|
||||
await new Promise<void>((r) => releases.push(r));
|
||||
return mine;
|
||||
});
|
||||
const older = app._syncCaseStateFromServer();
|
||||
await settle();
|
||||
const newer = app._syncCaseStateFromServer();
|
||||
await settle();
|
||||
releases[1](); // the newer answers first
|
||||
await newer;
|
||||
releases[0](); // then the older one arrives late
|
||||
await older;
|
||||
expect(select.value).toBe('alpha');
|
||||
});
|
||||
});
|
||||
@@ -125,7 +125,7 @@ async function boot(device: Device = 'desktop', stored?: Settings) {
|
||||
};
|
||||
var KeyboardHandler = {}, SwipeHandler = {}, DeepgramProvider = {}, NotificationManager = function () {};
|
||||
var KeyboardAccessoryBar = { setMode() {} };
|
||||
var VoiceInput = { _getDeepgramConfig: () => ({}), _saveDeepgramConfig() {}, refreshClaudeStatus: () => Promise.resolve() };
|
||||
var VoiceInput = { SILENCE_TIMEOUT_SECONDS: [0, 3, 6, 15, 30], _getDeepgramConfig: () => ({}), _saveDeepgramConfig() {}, refreshClaudeStatus: () => Promise.resolve() };
|
||||
var FocusTrap = function () { this.activate = () => {}; this.deactivate = () => {}; };
|
||||
var DEFAULT_VOICE_KEYTERMS = '';`,
|
||||
...SCRIPTS,
|
||||
|
||||
@@ -0,0 +1,439 @@
|
||||
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/);
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
// 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();
|
||||
});
|
||||
@@ -0,0 +1,394 @@
|
||||
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