mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-12 18:19:33 +01:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fa4815b465 | ||
|
|
a26be7c98f | ||
|
|
4423949923 | ||
|
|
5396e33b48 |
No files matched your search
@@ -92,7 +92,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
||||
- **ESM only** — Never `require()`, use `await import()`. `tsx` masks CJS/ESM issues in dev but production breaks
|
||||
- **Package ≠ product name** — npm: `aicodeman`, product: **Codeman**. Release renames tags accordingly. Both `aicodeman` and `codeman` bin aliases are installed (`package.json` `bin`)
|
||||
- **Global regex `lastIndex`** — Shared `g`-flag patterns in loops must reset `lastIndex = 0` first, or use the `execPattern()` helper in `utils/regex-patterns.ts` (resets automatically)
|
||||
- **`envOverrides` flow `CLAUDE_CODE_*` / `OPENCODE_*` / `CODEX_*` / `GEMINI_*` / `GOOGLE_*` / `ANTIGRAVITY_*` / `PI_*` / `GROK_*` / `XAI_*` / `DSH_*` / `DEEPSEEK_*` / `COPILOT_*` env vars, plus exact-key `CLAUDE_CONFIG_DIR`** — Set via `POST /api/sessions { envOverrides }`, stored on `Session._envOverrides`, exported by `tmux-manager.buildEnvExports()` at spawn time, persisted in `SessionState.envOverrides`. **Do NOT** write these to `<case>/.claude/settings.local.json` — that's the old path and creates UI/disk drift. (`GOOGLE_*` is the deliberately-broad Vertex-AI namespace for Gemini — see Multi-CLI prefix discipline.) `CLAUDE_CONFIG_DIR` (#255, exact match via `ALLOWED_ENV_KEYS` in `schemas.ts`) points a session at a separate Claude account/config dir for per-client subscriptions; it persists to state.json (a path, not a secret; losing it on restart would silently switch accounts). ⚠️ A relocated config dir writes transcripts outside `~/.claude/projects`, so the response viewer, subagent windows, ultracode panel and Read My Mind capture go blind for that session unless the user symlinks `projects` back into the shared tree (`ln -s ~/.claude/projects <configDir>/projects`). ⚠️ It is also one of claude's `privilegedEnvKeys` (Custom Model Endpoint Profiles, since it can redirect a session's traffic same as any other injected var), so in multi-user mode setting it via `envOverrides` is admin-only, and a non-granted owner's already-persisted `CLAUDE_CONFIG_DIR` is stripped on reboot-restore — silently returning that session to the default Claude account rather than the one it was pointed at (see `session-env-clamp.ts`). → [architecture-invariants#per-session-env-overrides-exact-key-allowlist-and-claude_config_dir](docs/architecture-invariants.md#per-session-env-overrides-exact-key-allowlist-and-claude_config_dir)
|
||||
- **`envOverrides` flow `CLAUDE_CODE_*` / `OPENCODE_*` / `CODEX_*` / `GEMINI_*` / `GOOGLE_*` / `ANTIGRAVITY_*` / `PI_*` / `GROK_*` / `XAI_*` / `DSH_*` / `DEEPSEEK_*` / `COPILOT_*` env vars, plus exact-key `CLAUDE_CONFIG_DIR`** — Set via `POST /api/sessions { envOverrides }`, stored on `Session._envOverrides`, exported by `tmux-manager.buildEnvExports()` at spawn time, persisted in `SessionState.envOverrides`. **Do NOT** write these to `<case>/.claude/settings.local.json` — that's the old path and creates UI/disk drift. (`GOOGLE_*` is the deliberately-broad Vertex-AI namespace for Gemini — see Multi-CLI prefix discipline.) `CLAUDE_CONFIG_DIR` (#255, exact match via `ALLOWED_ENV_KEYS` in `schemas.ts`) points a session at a separate Claude account/config dir for per-client subscriptions; it persists to state.json (a path, not a secret; losing it on restart would silently switch accounts). ⚠️ A relocated config dir writes transcripts outside `~/.claude/projects`, so subagent windows, the ultracode panel and Read My Mind capture go blind for that session unless the user symlinks `projects` back into the shared tree (`ln -s ~/.claude/projects <configDir>/projects`). The response viewer and the rename's `/resume` title read a LOCAL session's own folder, then the server's `CLAUDE_CONFIG_DIR`, then `~/.claude` (`claudeConversationDirs()` in session-routes.ts over `claudeProjectsDir()`, as the Edit history panel and the relaunch pin do, #701): a relocated folder is probed bounded first, and a missing one reads as no transcript, never as a reason to look under `~/.claude`. ⚠️ It is also one of claude's `privilegedEnvKeys` (Custom Model Endpoint Profiles, since it can redirect a session's traffic same as any other injected var), so in multi-user mode setting it via `envOverrides` is admin-only, and a non-granted owner's already-persisted `CLAUDE_CONFIG_DIR` is stripped on reboot-restore — silently returning that session to the default Claude account rather than the one it was pointed at (see `session-env-clamp.ts`). → [architecture-invariants#per-session-env-overrides-exact-key-allowlist-and-claude_config_dir](docs/architecture-invariants.md#per-session-env-overrides-exact-key-allowlist-and-claude_config_dir)
|
||||
- **Effort is NOT an env var** — never carry effort as `CLAUDE_CODE_EFFORT_LEVEL`: the env var hard-locks effort and blocks in-session `/effort` switching (incl. ultracode). It flows as the dedicated `effort` payload field → `Session._effort` → `claude --effort <level>` for regular levels incl. `max` (the settings `effortLevel` key is `enum(["low","medium","high","xhigh"]).catch(undefined)` — `max` gets SILENTLY dropped there), or `claude --settings '{"ultracode":true}'` for ultracode (rejected by `--effort`). Both are soft defaults the user can override anytime. Legacy env-var entries are auto-migrated by the Session constructor and unset from tmux sessions in `applyEnvOverrides()`. See `buildEffortCliArgs()` in `session-cli-builder.ts`, tests in `test/effort-injection.test.ts`
|
||||
- **The advisor rides `--settings`, NEVER the `--advisor` flag**: Claude Code's advisor tool (a stronger model consulted at decision points, code.claude.com/docs/en/advisor) flows as the `advisorModel` payload field → `Session._advisorModel` (persisted, so respawn and reboot restore keep it) → the `advisorModel` key in the launch's ONE `--settings` JSON, merged with ultracode and the statusLine exporter by `buildAdvisorSettings()` (`session-cli-builder.ts`). ⚠️ The flag EXITS at launch on any pairing the CLI refuses (`claude --advisor haiku` exits 1, so does Fable before its usage-credit consent), which would leave a dead pane on every respawn; the settings key degrades to "no advisor" instead. ⚠️ `isAdvisorModel()` (fable/opus/sonnet aliases or full ids, no haiku) is also the injection guard for the single-quoted argument. Soft default: `/advisor` still switches it in-session. App Settings key `claudeAdvisorModel` (SYNCED, `''` = leave it to the CLI). Remote/docker quick-start refuses it, like `effort`, and so does a remote attach on `POST /api/sessions`. Tests: `test/advisor-model.test.ts`
|
||||
- **Model choice: a persistent default in `settings.local.json`, a per-session `--model`, never env** — the App Settings **Claude Model** picker (`claudeModel` in `settings.json`) is read by `session-ui.js` at session create (wins over the legacy 1M-Opus toggles `opusContext1m`/`opusContext1mEnabled`), sent as the `modelOverride` payload field, and `updateCaseModel()` (`hooks-config.ts`) writes/deletes the `model` key in `<case>/.claude/settings.local.json`. This is the intended exception to the envOverrides rule above: model legitimately lives in `settings.local.json` (a soft default — in-session `/model` still works); env vars do not. A caller that wants one session on a model without touching the case sends `model` on `POST /api/sessions` instead: it goes out as `claude --model <id>`, writes nothing, wins over the app-wide default, and is persisted as `SessionState.model` so both recovery paths relaunch on it (`test/routes/session-routes-claude-model.test.ts`, `test/session-model-recovery.test.ts`). ⚠️ Claude only, via the `model.source` capability (`cliTakesSessionModel()`), never a mode check: refused for other CLIs and on a remote attach, and published by `toState()` for claude alone (cron hands its Claude default to every CLI with a model).
|
||||
@@ -194,7 +194,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
||||
|
||||
⚠️ **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 two rows, the MLX builds of Qwen3.6 35B-A3B and Muse Glimmer 30B, 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 install** (`POST /api/local-model/install|use|cancel|resume|runtime|agent`, `DELETE /api/local-model`, `DELETE /api/local-model/models`; `src/local-model/{install-job,runtime,ollama-client,connect,state-store,paths,developer-tools}.ts`; EXPERIMENTAL, admin only in multi-user mode on EVERY verb; guide `docs/local-models.md`): installs the chosen model on the Mac the server runs on and ends on a session of the coding agent running on it. ONE job of five idempotent steps (`runtime`, `model`, `verify`, `agent`, `connect`; `agent` only when the coding agent is missing), a pure reducer (`reduceInstallJob()`) over three tables in `install-job.ts` driven by one service per process (`getLocalModelService()`); the window polls the GET once a second, no SSE event. ⚠️ A LIBRARY with ONE model in use: the store may hold several models and `LocalModelStateFile.library` lists the ones that passed the check (written by the service when `verify` passes, carried by the reducer, dropped by `remove`; an `installed` file without it counts its one model, `libraryOf()`), but the endpoint record lists ONE model, the runtime holds one and only the state's model is ever launched: never widen the record to every checked model. `install` from `installed` adds a model (the earlier one stays: `replacedModelFor()` never names a library model), `use` switches to a checked one (a job of `USE_JOB_STEPS`, `verify` then `connect`; the stored digest is compared before anything is loaded, else `model-changed`), and both answer 409 `sessions-open` while a live session uses the endpoint; until such a job ends `installed`, no session starts (`launchRefusal()` answers `job-unfinished`). `DELETE /api/local-model/models` deletes ONE model by its NAME (`LOCAL_MODEL_ID_PATTERN`, never a path), only a name of the server's own listing, only through the runtime's own delete, never the model of the installation or of the current job (409 `model-active`), and it is no reducer event: its row is the `delete-model` action of `INSTALL_STATE_TABLE`. ⚠️ The view's `library` (state entries joined with the runtime's `/api/tags`: `ready`, `changed`, `missing`, `unverified`) and `store` (the blob folder's size, `measureModelStore()`) are cached (5 s, 30 s), refreshed in the background and thrown away by every verb and step that changes them (`forgetStore()`): never ask the runtime or the disk on the one-second poll, and a runtime that does not answer keeps the last list. ⚠️ Layout only through `localModelPaths()` (all `dataPath()`): `local-model/runtime/ollama-<version>/`, `local-model/models/`, `local-model/home/` (the runtime's PRIVATE HOME, so the user's `~/.ollama` is never touched) and the state file `local-model.json` (0600, ONE serialized writer, total reader, holds NO path); nothing is written or deleted outside `local-model/` except that file and the two writes of the connect step. ⚠️ The runtime pin (`OLLAMA_RUNTIME_PIN`: version, URL, size, sha256) is a COMPILED constant no file, setting or request may supply; the hash is compared BEFORE anything is unpacked. ⚠️ The runtime is a NON-detached child whose environment comes from the `runtimeEnv()` ALLOWLIST, never `process.env` (no `CODEMAN_*`, none of the user's `OLLAMA_*`); the port is chosen ONCE by binding (from 11435, never 11434) and stored; a listener on it is adopted or replaced only when its stored pid runs a binary under OUR runtime folder, anything else is `port-in-use`; started at boot when installed and enabled, max 3 restarts, SIGTERM at shutdown beside `stopDeepSeekWeb()`; under vitest nothing spawns or fetches. ⚠️ Consent is bound to the size: `acknowledgedBytes` must equal the server's own total (`Candidate.installBytes`) or the answer is 409 `size-changed`; the model, context and CLI must be a row the current recommendation offers (400). ⚠️ ONE job at a time (409 `job-running`); a job a restart interrupted comes back `paused` and NEVER continues without a click (so `shutdown()` writes nothing), and an interrupted step is never recorded as failed. ⚠️ An event the transition table does not list is refused (same object back, 409 `wrong-state`): add a row to the table, never a special case. ⚠️ The budget is MEASURED from the runtime's "inference compute" log line and the model judged against it BEFORE it is downloaded. ⚠️ The pull stream is parsed incrementally (about 1.4 million lines for 23 GB): never buffer it, never write state per line. ⚠️ A failure is a code with ONE next step (`LOCAL_MODEL_ERROR_NEXT`), never a bare "failed"; a probe that ended on its token limit is `probe-unfinished`, not `no-tool-call`. ⚠️ The coding agent is installed through the shared runner `src/cli-install-runner.ts` (extracted from `cli-registry-routes.ts`, ONE install slot per CLI for both callers) with `stockInstallCommand()`, the COMPILED stock command, never the `clis.json`-merged one (this path has no `cliManagementEnabled` gate); only for a catalLine truncated
|
||||
|
||||
|
||||
@@ -149,7 +149,7 @@ Spawn commands are pinned as literal strings in `test/cli-registry-spawn-golden.
|
||||
|
||||
### Per-session env overrides: exact-key allowlist and CLAUDE_CONFIG_DIR
|
||||
|
||||
**The env allowlist has two tiers, and exceptions go in the exact-key tier, never a widened prefix** (#255): `ALLOWED_ENV_PREFIXES` in `src/web/schemas.ts` carries the CLI-namespace prefixes, and `ALLOWED_ENV_KEYS` carries exact keys (currently only `CLAUDE_CONFIG_DIR`). `CLAUDE_CONFIG_DIR` relocates the Claude CLI's user config (credentials, settings, stats), which is how one machine runs sessions on separate Claude subscriptions: point a case's sessions at e.g. `~/.claude-clients/acme` via `envOverrides` and run `/login` there once against the client's account. The exact match matters: `CLAUDE_` as a prefix would open every future Claude CLI variable unreviewed, and near-misses (`CLAUDE_CONFIG_DIR_EXTRA`) stay rejected (`test/env-overrides-schema.test.ts`). No new security boundary is crossed: sessions already run as the server's OS account, and `applyEnvOverrides()` shellescapes values into socket-scoped `tmux setenv`. Two carry rules: **(1)** the key must survive `getEnvOverridesForPersist()` in `session.ts` (it is a path, not a secret; dropping it from state.json would silently move a rebuilt-after-reboot session back to the default account); **(2)** ⚠️ a relocated config dir writes transcripts outside `homedir()/.claude/projects`, which `subagent-watcher.ts`, `workflow-run-watcher.ts`, the response-viewer routes and Read My Mind capture all hardcode — those surfaces go blind for such a session. Documented workaround: symlink the transcripts back into the shared tree (`ln -s ~/.claude/projects <configDir>/projects`), keeping credentials separate while the watchers keep working.
|
||||
**The env allowlist has two tiers, and exceptions go in the exact-key tier, never a widened prefix** (#255): `ALLOWED_ENV_PREFIXES` in `src/web/schemas.ts` carries the CLI-namespace prefixes, and `ALLOWED_ENV_KEYS` carries exact keys (currently only `CLAUDE_CONFIG_DIR`). `CLAUDE_CONFIG_DIR` relocates the Claude CLI's user config (credentials, settings, stats), which is how one machine runs sessions on separate Claude subscriptions: point a case's sessions at e.g. `~/.claude-clients/acme` via `envOverrides` and run `/login` there once against the client's account. The exact match matters: `CLAUDE_` as a prefix would open every future Claude CLI variable unreviewed, and near-misses (`CLAUDE_CONFIG_DIR_EXTRA`) stay rejected (`test/env-overrides-schema.test.ts`). No new security boundary is crossed: sessions already run as the server's OS account, and `applyEnvOverrides()` shellescapes values into socket-scoped `tmux setenv`. Two carry rules: **(1)** the key must survive `getEnvOverridesForPersist()` in `session.ts` (it is a path, not a secret; dropping it from state.json would silently move a rebuilt-after-reboot session back to the default account); **(2)** ⚠️ a relocated config dir writes transcripts outside `homedir()/.claude/projects`, which `subagent-watcher.ts`, `workflow-run-watcher.ts` and Read My Mind capture all hardcode: those surfaces go blind for such a session. Documented workaround: symlink the transcripts back into the shared tree (`ln -s ~/.claude/projects <configDir>/projects`), keeping credentials separate while the watchers keep working. The response viewer is no longer one of them (#701): `claudeConversationDirs()` in `session-routes.ts` gives `lastResponseFor` (the viewer, `codeman agent read`, the Discord bridge's last message) and the rename's `/resume` title the folder the session's claude really writes to, `<config dir>/projects` and `<config dir>/history.jsonl`, resolved by `claudeProjectsDir()` like the relaunch pin and the Edit history panel: the session's own override, then the server's `CLAUDE_CONFIG_DIR` (a pane inherits it through tmux), then `~/.claude`. ⚠️ Only a LOCAL session reads its override: in a Docker case or on a remote host the value names a folder over there. ⚠️ A relocated folder is a user-chosen path, so it is probed bounded first (#516), and anything but a directory reads as "no transcript": never fall back to `~/.claude`, where a file of the same name is another account's conversation. The default folder is not probed, so that path is unchanged. Tests: `test/routes/session-routes-claude-last-response.test.ts` (relocated folder block), `test/routes/session-name-routes.test.ts`.
|
||||
|
||||
⚠️ **As of the custom-model endpoint feature, `CLAUDE_CONFIG_DIR` is ALSO admin-only in multi-user mode**, which is a change to the above rather than a restatement of it. It joined claude's `privilegedEnvKeys` (`stock.ts`) alongside `CLAUDE_CODE_MAX_CONTEXT_TOKENS`, because the rule that every traffic-redirecting var that feature can inject must be listed there is worth keeping literally true. `privilegedEnvKeys` has exactly one consumer, `ownerClampedEnvKeys()` in `src/session-env-clamp.ts`, which feeds the generic `envOverrides` clamp on `POST /api/sessions`, `POST /api/quick-start` and reboot-restore. So for a non-granted owner two things now follow: the key cannot be set through `envOverrides` at all, and an ALREADY-PERSISTED one is stripped on reboot-restore, which silently moves that session back to the default Claude account. That second consequence is the one to watch, since it turns a working per-client setup into a wrong-account one across a host reboot with no error anywhere. `session-env-clamp.ts`'s own fileoverview used to state the opposite invariant (that claude's privileged keys are the five `ANTHROPIC_*` names, so a persisted record cannot carry a clamped key) and was corrected when this landed; a claude clamp test now pins the behaviour next to the deepseek and omp ones.
|
||||
|
||||
|
||||
+21
-11
@@ -20,8 +20,8 @@ in [architecture-invariants.md](architecture-invariants.md#local-model-install).
|
||||
|
||||
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
|
||||
model of the catalogue that fits (eleven models today: a 16 GB Mac sees one of them, a 512 GB
|
||||
Mac all eleven). 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):
|
||||
|
||||
@@ -31,9 +31,9 @@ never more than the memory less 6, 8 or 12 GiB for macOS, Codeman, a browser and
|
||||
| 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 |
|
||||
| 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. Run once on a Mac (an M5 Pro with 48 GB, 2026-11-12): this GGUF build wrote 15 tokens per second, and an OpenCode worker given a one-line task showed no answer after 7 minutes. Its MLX build, which finished an OpenCode turn on a one-line prompt in 45 seconds, needs 36.1 GB and is offered from 48 GB |
|
||||
| 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 |
|
||||
| 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). A session of many prompts on that Mac (a 95,000-token prompt, then twelve separate ones of 15,000 tokens, measured on 2026-11-12) ended at 33.4 GB of the 40.2 GB its runtime measures, still growing by 0.5 GiB a prompt: MLX may keep up to 8 GiB of earlier prompts, which brings this pair to the edge of the budget. Muse Glimmer 30B as its MLX build is offered here too, at 65,536 tokens (36.1 GB, tight) |
|
||||
| 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 |
|
||||
@@ -73,7 +73,10 @@ names its runner (`runner`) and its figures follow that runner's rule:
|
||||
- **MLX** grows its cache with the tokens actually used, keeps snapshots of the recurrent
|
||||
state, and may keep up to 8 GiB of snapshots of earlier prompts, which no setting bounds. A
|
||||
Mac always takes the MLX build of a tag that has one. Below 48 GB that 8 GiB does not fit
|
||||
beside a useful model, so the catalogue lists GGUF builds there.
|
||||
beside a useful model, so the catalogue lists GGUF builds there. For Muse Glimmer the MLX
|
||||
runner was measured holding 38,000 to 45,000 bytes per token, against the 7,072 its GGUF
|
||||
build computes to: close to every layer's cache at full length, so it evidently does not
|
||||
trim the sliding-window layers the way llama.cpp does.
|
||||
|
||||
Where every figure comes from:
|
||||
|
||||
@@ -84,6 +87,7 @@ Where every figure comes from:
|
||||
| `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 |
|
||||
| `muse-glimmer:30b-mlx` | MLX | **measured** on an Apple M5 Pro with 48 GB, the 8 GiB of prefix snapshots counted | 5.4: measured beside the 35B-A3B on MLX, 0.32 of its 16.9 |
|
||||
| `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 |
|
||||
@@ -91,12 +95,18 @@ 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 (the Ornith 1.0 and Muse Glimmer rows on 2026-11-10, Muse Glimmer's MLX build on
|
||||
2026-11-12). Two rows carry memory figures measured on a Mac, both on an Apple M5 Pro with
|
||||
48 GB; every other answer in the table above is a calculation. Three more rows were installed
|
||||
and loaded on that Mac on 2026-11-12 (Qwen3.5 9B, Ornith 1.0 9B and Muse Glimmer as a GGUF
|
||||
build): that checked their download size and that they load on the GPU, not their memory
|
||||
figures. 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
|
||||
below). The 35B-A3B's figures come from single long prompts, not a session of many
|
||||
requests, in which MLX may keep up to 8 GiB more: such a session was measured on it later
|
||||
(the 48 GB row above) and its figures were left as they are. Muse Glimmer's MLX row counts
|
||||
the 8 GiB. 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,
|
||||
@@ -484,8 +494,8 @@ or `python3`.
|
||||
a session uses the model: close those sessions first.
|
||||
- **The runtime does not enforce the context length.** A prompt longer than the context was
|
||||
accepted in testing. Do not rely on the runtime to stop one.
|
||||
- **Most memory figures are calculated, not measured.** One model was run on one Mac; the
|
||||
others are computed from their architecture, and the runtime settings they assume (an 8-bit
|
||||
- **Most memory figures are calculated, not measured.** Two models were measured on one Mac;
|
||||
the 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
|
||||
|
||||
@@ -110,9 +110,10 @@ The defaults you will care about, all under **App Settings**:
|
||||
**Separate Claude accounts per session.** Set `CLAUDE_CONFIG_DIR` in a session's environment
|
||||
overrides to point it at a different Claude config directory, which is how you run one
|
||||
session on a client's subscription and another on your own. One caveat: a relocated config
|
||||
directory writes transcripts outside `~/.claude/projects`, which blinds the response viewer,
|
||||
subagent windows, ultracode panel, and Read My Mind for that session. Symlink `projects`
|
||||
back into the shared tree to keep them working:
|
||||
directory writes transcripts outside `~/.claude/projects`, which blinds subagent windows,
|
||||
the ultracode panel and Read My Mind for that session (the response viewer and the Edit
|
||||
history panel read the relocated directory). Symlink `projects` back into the shared tree
|
||||
to keep them working:
|
||||
|
||||
```bash
|
||||
ln -s ~/.claude/projects <configDir>/projects
|
||||
|
||||
@@ -108,9 +108,9 @@ are not reading.
|
||||
|
||||
- **A session pointed at a relocated Claude config directory goes blind here.** Transcripts
|
||||
written outside `~/.claude/projects` are invisible to the watchers, so subagent windows,
|
||||
the ultracode panel, the response viewer, and Read My Mind all stop working for that
|
||||
session. Symlink `projects` back into the shared tree to fix it. See
|
||||
[Agent CLIs](Agent-CLIs).
|
||||
the ultracode panel and Read My Mind stop working for that session. Symlink `projects`
|
||||
back into the shared tree to fix it. The response viewer reads the session's own
|
||||
directory and keeps working. See [Agent CLIs](Agent-CLIs).
|
||||
- **Closing a window does not cancel the agent.** Nothing on this page controls agents; it
|
||||
observes them.
|
||||
- **Windows are opt-in for ultracode, automatic for subagents.**
|
||||
|
||||
@@ -26,7 +26,8 @@
|
||||
* 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).
|
||||
* added on 2026-11-10, `ornith:9b-q4_k_m` and `muse-glimmer:30b-q4_k_m`, were read that day;
|
||||
* `muse-glimmer:30b-mlx` was read and measured on 2026-11-12).
|
||||
* [V] read at the source that day, [M] measured on a Mac, [C] computed from [V] inputs:
|
||||
*
|
||||
* | Figure | Source | Checked |
|
||||
@@ -152,6 +153,26 @@
|
||||
* of many requests, in which the prefix snapshots may add up to 8 GiB. Its figures stay
|
||||
* the measured ones (with 8 GiB on top it would not be offered on the Mac it ran on),
|
||||
* and a 30-request session on it is the first thing to measure next.
|
||||
* Measured again on 2026-11-12 on the same Mac, from the runner's own log line
|
||||
* (`msg=memory peak=... held=...` after every request): 39,600 bytes per token between
|
||||
* prompts of 30,000 and 94,500 tokens, and the session this row owed, twelve separate
|
||||
* prompts of 15,000 tokens, added 0.5 GiB of prefix snapshots each (5.6 GiB after
|
||||
* twelve, still growing): the whole run ended at 33.4 GB of the 40.2 GB that Mac's
|
||||
* runtime measures. The row's figures are unchanged by it.
|
||||
* - `muse-glimmer:30b-mlx`: MEASURED the same way on that Mac (2026-11-12, runtime
|
||||
* 0.40.15). What stayed held grew by 37,900 to 45,100 bytes per token between prompts
|
||||
* of 30,000, 60,000 and 94,600 tokens. The 13 full-attention layers alone compute to
|
||||
* 13,312 in bf16 and all 52 layers at full length to 53,248, so the runner evidently
|
||||
* does not trim the 39 sliding-window layers to their window (an inference from the
|
||||
* figure, not read in its source). The row takes the upper end. It counts the whole
|
||||
* 8 GiB prefix budget, because the same run filled it: twelve separate prompts of
|
||||
* 15,000 tokens added 0.53 GiB each until the runner dropped the oldest. The process
|
||||
* (`footprint`) stood 0.2 GB above the runner's own count at the end. Its lifetime
|
||||
* peak stood 3.0 to 3.3 GB above that, which is a FLOOR for the peak of a long prompt:
|
||||
* a lifetime maximum taken at the end of the run hides a transient of up to 9 GB
|
||||
* during the earlier, longer prompts. So the row takes the first measured row's 5 GB.
|
||||
* The tag carries a draft model; its tensors are inside `bytes`, and what it holds is
|
||||
* inside these figures.
|
||||
* - every other `mlx` row: held = the K/V cache in bf16 + one recurrent snapshot per
|
||||
* 8,192 tokens, computed from the config, x 37,000 / 28,340: the ratio of what the
|
||||
* measured row really held to the same computation for it (same runner, same
|
||||
@@ -204,9 +225,15 @@
|
||||
* 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.
|
||||
* - The `-dflash` tags of Muse Glimmer (`30b-q4_k_m-dflash`, `30b-nvfp4-dflash`): a separate
|
||||
* draft model whose memory no rule here covers. `muse-glimmer:30b-mlx`, which is built
|
||||
* from the second one's layers, IS listed since 2026-11-12, with measured figures. Its
|
||||
* two open runtime issues were read again that day: 17656 takes its `nvfp4` weights for
|
||||
* a build the MLX engine cannot accelerate (on the Mac it ran on the runtime logged
|
||||
* "MLX engine initialized ... device=gpu" and it wrote twice as fast as the GGUF build),
|
||||
* and 18269 reports it stuck in "Stopping..." and a restart on a 32 GB Mac, a size the
|
||||
* measured figures keep it away from (its maintainer promised a memory fix "in the next
|
||||
* release" in September; whether that predates the pinned 0.40.15 is not known).
|
||||
* - 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.
|
||||
@@ -407,13 +434,35 @@ const LLAMACPP_DEFAULT_DRAFT_TOKENS = 4;
|
||||
/** The prefix snapshots the MLX runner may keep: `maxPagedOutBytes`, 8 GiB. */
|
||||
const MLX_PREFIX_CACHE_BYTES = 8 * 1024 * 1024 * 1024;
|
||||
|
||||
/**
|
||||
* The memory figures measured for `muse-glimmer:30b-mlx` on the same Mac (2026-11-12, runtime
|
||||
* 0.40.15, the MLX runner), in decimal bytes: the runner's own `memory peak / held` log line
|
||||
* after every request, and the process footprint at the end. See the file header (MEMORY).
|
||||
*/
|
||||
const MUSE_MLX_MEASURED_MEMORY = {
|
||||
/** Held grew by 37,900 to 45,100 bytes per token between prompts of 30,000, 60,000 and 94,600 tokens: the upper end. */
|
||||
heldBytesPerToken: 45_000,
|
||||
/**
|
||||
* The prefix snapshots in full (the run filled them: 0.53 GiB per separate prompt of 15,000
|
||||
* tokens until the oldest was dropped), and 0.5 GB for what the process holds beside the
|
||||
* runner's own count (0.2 GB measured).
|
||||
*/
|
||||
overheadBytes: MLX_PREFIX_CACHE_BYTES + 500_000_000,
|
||||
/**
|
||||
* The first measured row's 5 GB. This run only gives a floor: the footprint's lifetime peak
|
||||
* stood 3.0 to 3.3 GB above what was held at the end, and a larger transient during the
|
||||
* earlier, longer prompts would not show in that figure.
|
||||
*/
|
||||
peakExtraBytes: 5_000_000_000,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* The catalogue that ships with Codeman. Never read directly outside this file and its test:
|
||||
* callers use `getLocalModelCatalog()`, which validates it first.
|
||||
*/
|
||||
export const STOCK_LOCAL_MODEL_CATALOG: LocalModelCatalog = {
|
||||
schema: 1,
|
||||
updated: '2026-11-10',
|
||||
updated: '2026-11-12',
|
||||
// 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
|
||||
@@ -537,11 +586,43 @@ export const STOCK_LOCAL_MODEL_CATALOG: LocalModelCatalog = {
|
||||
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).
|
||||
// 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). Left there on purpose after the one run on a Mac
|
||||
// (M5 Pro, 2026-11-12): the runtime alone wrote 14.5 to 15.2 tokens per second, 4.8 per
|
||||
// 100 GB/s, but an OpenCode worker given one line (through the skill's `sendmark`, which
|
||||
// adds its answer-file sentence) showed no answer after 7 minutes, twice.
|
||||
speedPer100Gbps: 2.95,
|
||||
tested: [],
|
||||
},
|
||||
{
|
||||
// The same model as the MLX build: about twice as fast, because the tag carries a draft
|
||||
// model (the runtime logs "Loaded draft model", acceptance 0.34 to 0.62) and its own
|
||||
// renderer and tool parser. On the MacBook Pro (Mac17,13, M5 Pro, 48 GB, 2026-11-12) it
|
||||
// wrote 30.2 tokens per second where the GGUF build wrote 15.2 (the same 300-token
|
||||
// request), answered a tool call in under 3 seconds, and finished an OpenCode turn on
|
||||
// a one-line prompt in 45 seconds. The GGUF build above had shown nothing after 7
|
||||
// minutes on a one-line task (not the same prompt: see that row). The price is
|
||||
// memory: what the MLX runner holds grows six times as fast per token (MEMORY in the
|
||||
// file header), so this build starts at 48 GB and the 32 and 36 GB Macs keep the row
|
||||
// above. Not installed through the window yet, and no agent session beyond that turn:
|
||||
// no `tested` entry.
|
||||
id: 'muse-glimmer:30b-mlx',
|
||||
label: 'Muse Glimmer 30B (MLX)',
|
||||
license: 'Apache-2.0',
|
||||
paramsB: 29.6,
|
||||
runner: 'mlx',
|
||||
// The layers of its manifest, the draft model's included.
|
||||
bytes: 19_053_621_992,
|
||||
...MUSE_MLX_MEASURED_MEMORY,
|
||||
figures: 'measured',
|
||||
contexts: [131072, 65536],
|
||||
rank: 6,
|
||||
// Measured beside Qwen3.6 35B-A3B on MLX, same Mac, same request (a 1,500-token answer):
|
||||
// 32.6 against 101.9 tokens per second, so 0.32 of that row's 16.9. Like that row's, on
|
||||
// the low side: on 307 GB/s it computes to 16.6.
|
||||
speedPer100Gbps: 5.4,
|
||||
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.
|
||||
@@ -562,7 +643,7 @@ export const STOCK_LOCAL_MODEL_CATALOG: LocalModelCatalog = {
|
||||
peakExtraBytes: 0,
|
||||
figures: 'estimated',
|
||||
contexts: [262144, 131072, 65536],
|
||||
rank: 6,
|
||||
rank: 7,
|
||||
// Measured: 54.5 tokens per second on 400 GB/s (llama.cpp, without prediction).
|
||||
speedPer100Gbps: 13.6,
|
||||
tested: [],
|
||||
@@ -580,7 +661,7 @@ export const STOCK_LOCAL_MODEL_CATALOG: LocalModelCatalog = {
|
||||
...MEASURED_MEMORY,
|
||||
figures: 'measured',
|
||||
contexts: [131072, 65536],
|
||||
rank: 7,
|
||||
rank: 8,
|
||||
// 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,
|
||||
@@ -605,7 +686,7 @@ export const STOCK_LOCAL_MODEL_CATALOG: LocalModelCatalog = {
|
||||
peakExtraBytes: 0,
|
||||
figures: 'estimated',
|
||||
contexts: [262144, 131072, 65536],
|
||||
rank: 8,
|
||||
rank: 9,
|
||||
// Not measured: the 2.95 of the row below scaled by file size.
|
||||
speedPer100Gbps: 1.7,
|
||||
tested: [],
|
||||
@@ -629,7 +710,7 @@ export const STOCK_LOCAL_MODEL_CATALOG: LocalModelCatalog = {
|
||||
peakExtraBytes: 0,
|
||||
figures: 'estimated',
|
||||
contexts: [262144, 131072, 65536],
|
||||
rank: 9,
|
||||
rank: 10,
|
||||
// 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,
|
||||
@@ -653,7 +734,7 @@ export const STOCK_LOCAL_MODEL_CATALOG: LocalModelCatalog = {
|
||||
peakExtraBytes: MEASURED_MEMORY.peakExtraBytes,
|
||||
figures: 'estimated',
|
||||
contexts: [262144, 131072],
|
||||
rank: 10,
|
||||
rank: 11,
|
||||
// Not measured: the 16.9 of Qwen3.6 35B-A3B on MLX x 3/10, its active parameters
|
||||
// against these, rounded down.
|
||||
speedPer100Gbps: 5,
|
||||
|
||||
@@ -112,7 +112,7 @@ export const LIMITED_BELOW_PARAMS_B = 12;
|
||||
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
|
||||
* holds (11 models: 10 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.
|
||||
*/
|
||||
|
||||
@@ -216,6 +216,7 @@ import {
|
||||
import { resolveCaseRepository, resolveManagedWorktree } from '../../git-worktrees.js';
|
||||
import { LRUMap } from '../../utils/lru-map.js';
|
||||
import { describeUnknownPath, probePathKind, type PathProbeOptions } from '../../utils/index.js';
|
||||
import { claudeProjectsDir } from '../../utils/claude-transcript.js';
|
||||
import { findLatestOmpSessionId } from '../../utils/omp-session-resolver.js';
|
||||
import { scanOmpSessionsHistory } from '../../omp-transcript.js';
|
||||
import { scanCodexSessionsHistory, codexThreadBySessionId } from '../../codex-transcript.js';
|
||||
@@ -2859,7 +2860,8 @@ export function registerSessionRoutes(
|
||||
const claudeHistoryPinCache = new LRUMap<string, { submitAt: number; claudeSessionId: string }>({ maxSize: 1024 });
|
||||
async function resolveActiveClaudeSessionIdFromHistory(
|
||||
session: Session,
|
||||
projectsDir: string
|
||||
projectsDir: string,
|
||||
historyPath: string
|
||||
): Promise<string | null> {
|
||||
// A pane whose conversation id came from its OWN hook needs no correlation:
|
||||
// $CODEMAN_SESSION_ID (the pane's env) -> data.session_id (the CLI's own
|
||||
@@ -2886,7 +2888,6 @@ export function registerSessionRoutes(
|
||||
if (s.lastSubmitAt) otherSubmits.push(s.lastSubmitAt);
|
||||
}
|
||||
|
||||
const historyPath = join(homedir(), '.claude', 'history.jsonl');
|
||||
const stat = await fs.stat(historyPath).catch(() => null);
|
||||
if (!stat || stat.size === 0) return null;
|
||||
const tail = await readFileTail(historyPath, Buffer.alloc(CLAUDE_HISTORY_TAIL_BYTES), stat.size);
|
||||
@@ -3090,6 +3091,32 @@ export function registerSessionRoutes(
|
||||
return full ? { text: lastText, timestamp: lastTimestamp, messages } : { text: lastText, timestamp: lastTimestamp };
|
||||
}
|
||||
|
||||
/**
|
||||
* Where THIS session's claude keeps its conversations: `<config dir>/projects` and
|
||||
* `<config dir>/history.jsonl`. The session's own `CLAUDE_CONFIG_DIR` override (#255) comes
|
||||
* first, then the server's (a pane inherits it through tmux), then `~/.claude`: the order
|
||||
* `claudeProjectsDir()` gives the relaunch pin and the Edit history panel. Looking under
|
||||
* `~/.claude` alone showed "No response yet" for every session on a relocated folder (#701).
|
||||
*
|
||||
* ⚠️ A relocated folder is a path somebody chose, and it can sit on a mount that stopped
|
||||
* answering, so it is probed bounded first (#516, without `pastCap`: agents poll this).
|
||||
* Anything but a directory answers null, which callers read as "no transcript", never as a
|
||||
* reason to look under `~/.claude` instead: a file of the same name there is another
|
||||
* conversation. The default folder is not probed, so that path is unchanged.
|
||||
*
|
||||
* ⚠️ Local sessions only read their own override: in a Docker case or on a remote host the
|
||||
* value names a folder over there, and the same path on this host is somebody else's.
|
||||
*/
|
||||
async function claudeConversationDirs(
|
||||
session: Session
|
||||
): Promise<{ projectsDir: string; historyPath: string } | null> {
|
||||
const local = !session.remote && !session.docker;
|
||||
const projectsDir = claudeProjectsDir(local ? session.claudeConfigDir() : undefined);
|
||||
const relocated = projectsDir !== join(homedir(), '.claude', 'projects');
|
||||
if (relocated && (await probePathKind(projectsDir)) !== 'directory') return null;
|
||||
return { projectsDir, historyPath: join(dirname(projectsDir), 'history.jsonl') };
|
||||
}
|
||||
|
||||
/**
|
||||
* Mirror a rename into the conversation's `/resume` title (claude-session-title.ts).
|
||||
* Local Claude-format transcripts only: a remote pane's transcript lives on the remote host and a
|
||||
@@ -3105,11 +3132,13 @@ export function registerSessionRoutes(
|
||||
)
|
||||
return;
|
||||
try {
|
||||
const projectsDir = join(process.env.HOME || '/tmp', '.claude', 'projects');
|
||||
const hookPath = ctx.getTranscriptPath(session.id);
|
||||
const dirs = hookPath ? null : await claudeConversationDirs(session);
|
||||
const transcript = hookPath
|
||||
? { sessionId: basename(hookPath, '.jsonl'), path: hookPath }
|
||||
: await findClaudeTranscript(projectsDir, session.claudeSessionId || session.id, session.id);
|
||||
: dirs
|
||||
? await findClaudeTranscript(dirs.projectsDir, session.claudeSessionId || session.id, session.id)
|
||||
: null;
|
||||
if (transcript) await appendClaudeCustomTitle(transcript.path, transcript.sessionId, session.name);
|
||||
} catch (err) {
|
||||
console.warn(`[Session] Could not carry rename into the Claude transcript for ${session.id}:`, err);
|
||||
@@ -3264,13 +3293,21 @@ export function registerSessionRoutes(
|
||||
}
|
||||
}
|
||||
|
||||
// Scan ~/.claude/projects/*/ for the transcript file
|
||||
const projectsDir = join(process.env.HOME || '/tmp', '.claude', 'projects');
|
||||
// `turn` is the brief view's context: the last ANSWERED turn's assistant
|
||||
// messages, so a multi-row answer is not reduced to its final row. `text`
|
||||
// stays the last assistant row in every mode (agent pollers hash it).
|
||||
const wantsMessages = context === 'full' || context === 'turn';
|
||||
|
||||
// Scan <config dir>/projects/*/ for the transcript file: the session's own Claude config
|
||||
// folder when it was relocated, else the server's, else ~/.claude.
|
||||
const dirs = await claudeConversationDirs(session);
|
||||
if (!dirs) return wantsMessages ? { text: '', timestamp: '', messages: [] } : { text: '', timestamp: '' };
|
||||
const { projectsDir, historyPath } = dirs;
|
||||
|
||||
// Adopt the current conversation id if the user ran `/clear` — Claude CLI's
|
||||
// interactive PTY emits no JSON on stdout, so without this lookup the
|
||||
// stored id stays pinned to the pre-/clear transcript.
|
||||
const activeId = await resolveActiveClaudeSessionIdFromHistory(session, projectsDir);
|
||||
const activeId = await resolveActiveClaudeSessionIdFromHistory(session, projectsDir, historyPath);
|
||||
if (activeId && activeId !== session.claudeSessionId) {
|
||||
session.adoptClaudeSessionId(activeId);
|
||||
// Flush the Enter that vouched for this adoption to state.json. A `/clear`
|
||||
@@ -3286,10 +3323,6 @@ export function registerSessionRoutes(
|
||||
}
|
||||
}
|
||||
|
||||
// `turn` is the brief view's context: the last ANSWERED turn's assistant
|
||||
// messages, so a multi-row answer is not reduced to its final row. `text`
|
||||
// stays the last assistant row in every mode (agent pollers hash it).
|
||||
const wantsMessages = context === 'full' || context === 'turn';
|
||||
const claudeSessionId = session.claudeSessionId || session.id;
|
||||
const transcript = await findClaudeTranscript(projectsDir, claudeSessionId, session.id);
|
||||
if (!transcript) {
|
||||
|
||||
@@ -106,6 +106,9 @@ const ROW_ARCHITECTURE: Record<string, { architecture: ArchitectureName; draftTo
|
||||
'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 },
|
||||
// The MLX build. Its figures are measured, so nothing is recomputed from this entry. The tag
|
||||
// carries a draft MODEL (not a prediction layer of llama.cpp): what that holds was measured too.
|
||||
'muse-glimmer:30b-mlx': { 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 },
|
||||
@@ -167,6 +170,12 @@ function mlxComputedPerToken(arch: Architecture): number {
|
||||
|
||||
/** The figures measured for `qwen3.6:35b-a3b` on the MacBook Pro. */
|
||||
const MEASURED: [number, number, number] = [37_000, 3_000_000_000, 5_000_000_000];
|
||||
/**
|
||||
* The figures measured for `muse-glimmer:30b-mlx` on the same MacBook Pro (2026-11-12): the upper
|
||||
* end of what stayed held per token, the whole prefix budget plus 0.5 GB, and for the peak the
|
||||
* first measured row's 5 GB (its own run gave only a floor of 3.0 to 3.3 GB).
|
||||
*/
|
||||
const MEASURED_MUSE_MLX: [number, number, number] = [45_000, 8 * GIB + 500_000_000, 5_000_000_000];
|
||||
|
||||
/** What an estimated `mlx` row's three figures must be: computed, scaled by what the measured row showed. */
|
||||
function mlxFigures(arch: Architecture): [number, number, number] {
|
||||
@@ -193,22 +202,23 @@ 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 eleven 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',
|
||||
'muse-glimmer:30b-mlx',
|
||||
'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, 9, 10, 11]);
|
||||
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|4b)|ripple-preview|nemotron-3\.5-super|qwen3-coder-next)|^ornith-1\.5:(9b|35b|397b)|^ornith:35b|^muse-glimmer:.*dflash/;
|
||||
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']) {
|
||||
@@ -223,6 +233,8 @@ describe('the stock catalogue', () => {
|
||||
'mimo-v2.6:9b-q4_k_m': 'llamacpp',
|
||||
'north-mini-code-1.0:q4_k_m': 'llamacpp',
|
||||
'muse-glimmer:30b-q4_k_m': 'llamacpp',
|
||||
// Its manifest names no runner in a list; the runtime starts its MLX runner for it.
|
||||
'muse-glimmer:30b-mlx': 'mlx',
|
||||
'qwen3.6:35b-a3b-mtp-q4_k_m': 'llamacpp',
|
||||
// Manifest lists: a Mac takes the MLX child.
|
||||
'qwen3.6:35b-a3b': 'mlx',
|
||||
@@ -242,6 +254,9 @@ describe('the stock catalogue', () => {
|
||||
'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,
|
||||
// Fetched on 2026-11-12: 1,503 layers, the draft model's among them. A pull on a Mac
|
||||
// that day ended at 19.1 GB.
|
||||
'muse-glimmer:30b-mlx': 19_053_621_992,
|
||||
'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,
|
||||
@@ -252,9 +267,29 @@ describe('the stock catalogue', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('says which row was measured on a Mac: one, and every other row is an estimate', () => {
|
||||
expect(models.filter((m) => m.figures === 'measured').map((m) => m.id)).toEqual(['qwen3.6:35b-a3b']);
|
||||
it('says which rows were measured on a Mac: two, and every other row is an estimate', () => {
|
||||
expect(models.filter((m) => m.figures === 'measured').map((m) => m.id)).toEqual([
|
||||
'muse-glimmer:30b-mlx',
|
||||
'qwen3.6:35b-a3b',
|
||||
]);
|
||||
expect(figuresOf(models.find((m) => m.id === 'qwen3.6:35b-a3b') as LocalModelEntry)).toEqual(MEASURED);
|
||||
expect(figuresOf(models.find((m) => m.id === 'muse-glimmer:30b-mlx') as LocalModelEntry)).toEqual(
|
||||
MEASURED_MUSE_MLX
|
||||
);
|
||||
});
|
||||
|
||||
it('counts the whole prefix budget of the MLX runner in the row that was measured filling it', () => {
|
||||
// Every estimated MLX row counts the 8 GiB too (mlxFigures). The first measured row does
|
||||
// not: the catalogue header says why, and what a session on it really held.
|
||||
expect(MEASURED_MUSE_MLX[1]).toBe(8 * GIB + 500_000_000);
|
||||
expect(MEASURED[1]).toBeLessThan(8 * GIB);
|
||||
// What the runner really keeps per token is far above the 13 full-attention layers alone
|
||||
// and under all 52 layers at full length: it evidently does not trim the sliding-window layers.
|
||||
const arch = ARCHITECTURES['Muse-Glimmer-30B'];
|
||||
expect(arch.attentionLayers * arch.kvHeads * arch.headDim * 2 * 2).toBe(13_312);
|
||||
expect((arch.attentionLayers + arch.sliding.layers) * arch.kvHeads * arch.headDim * 2 * 2).toBe(53_248);
|
||||
expect(MEASURED_MUSE_MLX[0]).toBeGreaterThan(13_312 * 2);
|
||||
expect(MEASURED_MUSE_MLX[0]).toBeLessThanOrEqual(53_248);
|
||||
});
|
||||
|
||||
it('has one tested entry: the 35B-A3B with OpenCode on the MacBook Pro, with its measured speed', () => {
|
||||
@@ -277,6 +312,7 @@ describe('the stock catalogue', () => {
|
||||
'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'],
|
||||
'muse-glimmer:30b-mlx': [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'],
|
||||
@@ -309,6 +345,8 @@ describe('the stock catalogue', () => {
|
||||
'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,
|
||||
// Measured beside the 35B-A3B on MLX, same Mac and request: 32.6 against 101.9, x 16.9.
|
||||
'muse-glimmer:30b-mlx': 5.4,
|
||||
// 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.
|
||||
@@ -405,6 +443,9 @@ describe('the memory figures of the stock rows: each row has its own', () => {
|
||||
'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],
|
||||
// Measured: the upper end per token, 8 GiB of prefix snapshots + 0.5 GB. The peak is the
|
||||
// first measured row's: this run gave only a floor for it (3.0 to 3.3 GB).
|
||||
'muse-glimmer:30b-mlx': [45_000, 9_089_934_592, 5_000_000_000],
|
||||
// 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],
|
||||
|
||||
@@ -501,6 +501,9 @@ describe('needBytes', () => {
|
||||
'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,
|
||||
// The MLX build at the same window: 19.1 GB + 5.9 GB of cache (45,000 bytes per token,
|
||||
// measured) + the 8 GiB of prefix snapshots and 0.5 GB + 5 GB of peak.
|
||||
'muse-glimmer:30b-mlx': 39_041_796_584,
|
||||
'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,
|
||||
@@ -667,6 +670,9 @@ describe('the MacBook Pro (Mac17,13: Apple M5 Pro, 48 GB)', () => {
|
||||
).toEqual([
|
||||
// The same model as a GGUF build: its fixed footprint holds the full window, 28.8 GB.
|
||||
['qwen3.6:35b-a3b-mtp-q4_k_m', 262144, 'good', 'ok', 41.8, undefined],
|
||||
// Muse Glimmer as the MLX build: 36.1 GB at 65536 tokens (0.960 of the estimate), and
|
||||
// 5.4 x 3.07 = 16.6 tokens per second. At 131072 it needs 39.0 GB, 1.04: out.
|
||||
['muse-glimmer:30b-mlx', 65536, 'tight', 'ok', 16.6, 'tight'],
|
||||
['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'],
|
||||
@@ -677,11 +683,17 @@ describe('the MacBook Pro (Mac17,13: Apple M5 Pro, 48 GB)', () => {
|
||||
['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']));
|
||||
// Beside the tested pick, one more row rests on a measurement of its own model's memory.
|
||||
expect(result.alternatives.filter((c) => c.needSource === 'measured').map((c) => c.modelId)).toEqual([
|
||||
'muse-glimmer:30b-mlx',
|
||||
]);
|
||||
// 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(
|
||||
rows(result)
|
||||
.map((c) => c.modelId)
|
||||
.sort()
|
||||
).toEqual(fitting.map((m) => m.id).sort());
|
||||
// The 122B fits at no context.
|
||||
expect(order(result).some((entry) => entry.startsWith('ornith-1.5'))).toBe(false);
|
||||
});
|
||||
@@ -715,6 +727,7 @@ 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-mlx', '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'],
|
||||
@@ -825,11 +838,7 @@ describe('the Mac mini (Mac16,10: Apple M4, 16 GB)', () => {
|
||||
});
|
||||
// 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',
|
||||
]);
|
||||
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']);
|
||||
});
|
||||
|
||||
it('gets the same with Claude Code as its only coding agent, which also needs 65536 tokens', async () => {
|
||||
@@ -911,6 +920,22 @@ describe('every memory size, with the stock catalogue', () => {
|
||||
expect(smallest / result.budgetBytes).toBeGreaterThan(4);
|
||||
});
|
||||
|
||||
it('offers Muse Glimmer as the MLX build from 48 GB only, and never as the pick of a Mac whose disk has room', () => {
|
||||
// Measured on a 48 GB Mac: 45,000 bytes held per token and up to 8 GiB of prefix snapshots,
|
||||
// 36.1 GB at 65536 tokens. Runtime issue 18269 reports this build stuck, and a restart, on
|
||||
// a 32 GB Mac: the figures keep it away from there.
|
||||
for (const hw of SWEEP) {
|
||||
const result = recommend(hw, STOCK, ALL_INSTALLED);
|
||||
const label = `${hw.memClassGb} GB ${hw.chip}`;
|
||||
const offered = rows(result).some((c) => c.modelId === 'muse-glimmer:30b-mlx');
|
||||
expect(offered, label).toBe(hw.memClassGb >= 48);
|
||||
expect(result.pick?.modelId, label).not.toBe('muse-glimmer:30b-mlx');
|
||||
}
|
||||
// The 32 GB Mac with a Max chip keeps the GGUF build as its pick.
|
||||
const m1max = recommend(mac(32, 'Apple M1 Max'), STOCK, ALL_INSTALLED);
|
||||
expect(m1max.pick?.modelId).toBe('muse-glimmer:30b-q4_k_m');
|
||||
});
|
||||
|
||||
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', () => {
|
||||
for (const hw of SWEEP) {
|
||||
const result = recommend(hw, STOCK, ALL_INSTALLED);
|
||||
@@ -923,7 +948,10 @@ describe('every memory size, with the stock catalogue', () => {
|
||||
// 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(
|
||||
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 {
|
||||
@@ -1342,10 +1370,18 @@ describe('verdict none', () => {
|
||||
const oneShort = recommend(withFree(cut - 1), STOCK, ALL_INSTALLED);
|
||||
expect(oneShort.pick?.modelId).toBe('qwen3.6:35b-a3b-mtp-q4_k_m');
|
||||
expect(oneShort.headline.text).toBe(LOCAL_MODEL_LABELS.headlineOk);
|
||||
// At 33 GB neither 35B-A3B build has room: North Mini Code, and the tested pair is named.
|
||||
// At 33 GB neither 35B-A3B build has room: the next rank whose speed is `ok` and whose
|
||||
// download fits is Muse Glimmer as the MLX build (19.1 GB), and the tested pair is named.
|
||||
const short = recommend(withFree(33 * GB), STOCK, ALL_INSTALLED);
|
||||
expect(short.verdict).toBe('ok');
|
||||
expect(short.pick?.modelId).toBe('north-mini-code-1.0:q4_k_m');
|
||||
expect(short.pick).toMatchObject({
|
||||
modelId: 'muse-glimmer:30b-mlx',
|
||||
context: 65536,
|
||||
fit: 'tight',
|
||||
tested: false,
|
||||
});
|
||||
// Below 29.8 GB that build has no room either, and North Mini Code (18.6 GB) is the pick.
|
||||
expect(recommend(withFree(29.5 * GB), STOCK, ALL_INSTALLED).pick?.modelId).toBe('north-mini-code-1.0:q4_k_m');
|
||||
expect(short.notes).toEqual([
|
||||
{ text: LOCAL_MODEL_LABELS.noteDiskDropped, vars: { model: 'Qwen3.6 35B-A3B', missing: '0.8' } },
|
||||
{ text: LOCAL_MODEL_LABELS.noteUntested },
|
||||
@@ -2167,18 +2203,20 @@ 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
|
||||
// The same models in the same order. The share of each changes, and two models step 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).
|
||||
// what the runtime reported (0.976), and Muse Glimmer as the MLX build goes from 65536
|
||||
// (0.960) to its whole window of 131072 (0.972).
|
||||
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(stepped).toEqual(['muse-glimmer:30b-mlx@65536', 'qwen3.8:27b-q8_0@65536']);
|
||||
expect(order(measured)).toContain('qwen3.8:27b-q8_0@131072');
|
||||
expect(order(measured)).toContain('muse-glimmer:30b-mlx@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,
|
||||
0.957, 0.767, 0.96, 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,
|
||||
0.895, 0.717, 0.972, 0.647, 0.283, 0.26, 0.283, 0.798, 0.976, 0.539,
|
||||
]);
|
||||
for (const candidate of rows(measured)) {
|
||||
expect(candidate.budgetShare).toBeCloseTo(candidate.needBytes / MACBOOK_MEASURED_TOTAL, 3);
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { registerSessionRoutes } from '../../src/web/routes/session-routes.js';
|
||||
@@ -131,6 +131,35 @@ describe('PUT /api/sessions/:id/name', () => {
|
||||
expect(transcriptRows()).toHaveLength(before + 1);
|
||||
});
|
||||
|
||||
it("finds the transcript in the session's relocated Claude config folder when no hook has named it (#701)", async () => {
|
||||
// Before the first hook after a restart the watcher has no path, and the scan used to
|
||||
// look under ~/.claude alone, so a session on another account kept its old /resume title.
|
||||
const configDir = join(transcriptDir, 'claude-clients', 'acme');
|
||||
const projectDir = join(configDir, 'projects', '-tmp');
|
||||
mkdirSync(projectDir, { recursive: true });
|
||||
const relocatedPath = join(projectDir, `${session.id}.jsonl`);
|
||||
writeFileSync(relocatedPath, `${JSON.stringify({ type: 'user', message: { role: 'user', content: 'hi' } })}\n`);
|
||||
const hookPath = harness.ctx.getTranscriptPath;
|
||||
(harness.ctx as Record<string, unknown>).getTranscriptPath = () => null;
|
||||
Object.defineProperty(session, 'claudeConfigDir', { configurable: true, value: () => configDir });
|
||||
try {
|
||||
const res = await harness.app.inject({
|
||||
method: 'PUT',
|
||||
url: `/api/sessions/${session.id}/name`,
|
||||
payload: { name: 'client work' },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const rows = readFileSync(relocatedPath, 'utf8')
|
||||
.split('\n')
|
||||
.filter(Boolean)
|
||||
.map((line) => JSON.parse(line) as Record<string, unknown>);
|
||||
expect(rows.at(-1)).toEqual({ type: 'custom-title', customTitle: 'client work', sessionId: session.id });
|
||||
} finally {
|
||||
delete (session as unknown as Record<string, unknown>).claudeConfigDir;
|
||||
(harness.ctx as Record<string, unknown>).getTranscriptPath = hookPath;
|
||||
}
|
||||
});
|
||||
|
||||
it('writes no title row for a docker session, whose transcript lives in the container', async () => {
|
||||
const before = transcriptRows().length;
|
||||
Object.defineProperty(session, 'docker', { configurable: true, get: () => ({ caseName: 'c' }) });
|
||||
|
||||
@@ -598,3 +598,141 @@ describe('GET /api/sessions/:id/last-response (claude conversation pinning)', ()
|
||||
expect(near.adoptClaudeSessionId).toHaveBeenCalledWith('fresh-conversation');
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/sessions/:id/last-response (relocated Claude config folder, #701)', () => {
|
||||
let harness: LocalHarness;
|
||||
let testHome: string;
|
||||
let relocated: string;
|
||||
let previousHome: string | undefined;
|
||||
let previousConfigDir: string | undefined;
|
||||
const WORKDIR = '/workspace';
|
||||
const NOW = 1_770_000_000_000;
|
||||
|
||||
beforeEach(async () => {
|
||||
testHome = mkdtempSync(join(tmpdir(), 'codeman-claude-relocated-'));
|
||||
relocated = join(testHome, 'claude-clients', 'acme');
|
||||
previousHome = process.env.HOME;
|
||||
previousConfigDir = process.env.CLAUDE_CONFIG_DIR;
|
||||
process.env.HOME = testHome;
|
||||
delete process.env.CLAUDE_CONFIG_DIR;
|
||||
harness = await createEnvelopeHarness();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (previousHome === undefined) delete process.env.HOME;
|
||||
else process.env.HOME = previousHome;
|
||||
if (previousConfigDir === undefined) delete process.env.CLAUDE_CONFIG_DIR;
|
||||
else process.env.CLAUDE_CONFIG_DIR = previousConfigDir;
|
||||
rmSync(testHome, { recursive: true, force: true });
|
||||
await harness.app.close();
|
||||
});
|
||||
|
||||
/** A one-answer transcript under `<configDir>/projects`, stamped at `mtimeMs`. */
|
||||
function writeTranscript(configDir: string, conversationId: string, text: string, mtimeMs = NOW - 60_000): void {
|
||||
const projectDir = join(configDir, 'projects', '-workspace');
|
||||
mkdirSync(projectDir, { recursive: true });
|
||||
const path = join(projectDir, `${conversationId}.jsonl`);
|
||||
writeFileSync(
|
||||
path,
|
||||
JSON.stringify({
|
||||
type: 'assistant',
|
||||
timestamp: new Date(mtimeMs).toISOString(),
|
||||
message: { content: [{ type: 'text', text }] },
|
||||
})
|
||||
);
|
||||
utimesSync(path, mtimeMs / 1000, mtimeMs / 1000);
|
||||
}
|
||||
|
||||
function writeHistory(configDir: string, sessionId: string, timestamp: number): void {
|
||||
mkdirSync(configDir, { recursive: true });
|
||||
writeFileSync(
|
||||
join(configDir, 'history.jsonl'),
|
||||
JSON.stringify({ display: 'prompt', project: WORKDIR, sessionId, timestamp })
|
||||
);
|
||||
}
|
||||
|
||||
/** Replaces the pre-seeded mock session with a Claude pane in WORKDIR. */
|
||||
function addPane(conversationId: string, opts: { configDir?: string; lastSubmitAt?: number; docker?: boolean } = {}) {
|
||||
const base = harness.ctx._session;
|
||||
const pane = Object.create(Object.getPrototypeOf(base)) as typeof base & {
|
||||
claudeSessionId: string;
|
||||
lastSubmitAt: number;
|
||||
claudeSessionIdIsFirstHand: boolean;
|
||||
claudeConfigDirOverride: string | undefined;
|
||||
adoptClaudeSessionId: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
Object.assign(pane, base, {
|
||||
id: 'pane-1',
|
||||
mode: 'claude',
|
||||
workingDir: WORKDIR,
|
||||
docker: opts.docker ? { containerName: 'codeman-case-acme' } : undefined,
|
||||
});
|
||||
pane.claudeSessionId = conversationId;
|
||||
pane.lastSubmitAt = opts.lastSubmitAt ?? 0;
|
||||
pane.claudeSessionIdIsFirstHand = false;
|
||||
pane.claudeConfigDirOverride = opts.configDir;
|
||||
pane.adoptClaudeSessionId = vi.fn((newId: string) => {
|
||||
pane.claudeSessionId = newId;
|
||||
});
|
||||
harness.ctx.sessions.set(pane.id, pane);
|
||||
return pane;
|
||||
}
|
||||
|
||||
async function getLastResponse(query = '') {
|
||||
const response = await harness.app.inject({ method: 'GET', url: `/api/sessions/pane-1/last-response${query}` });
|
||||
return JSON.parse(response.body).data as { text: string; messages?: unknown[] };
|
||||
}
|
||||
|
||||
it("reads the answer from the session's own config folder", async () => {
|
||||
addPane('conversation-a', { configDir: relocated });
|
||||
writeTranscript(relocated, 'conversation-a', 'the answer in the relocated folder');
|
||||
|
||||
expect((await getLastResponse()).text).toBe('the answer in the relocated folder');
|
||||
expect((await getLastResponse('?context=full')).messages).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("reads the server's own CLAUDE_CONFIG_DIR for a session without an override", async () => {
|
||||
process.env.CLAUDE_CONFIG_DIR = relocated;
|
||||
addPane('conversation-a');
|
||||
writeTranscript(relocated, 'conversation-a', 'the answer in the server folder');
|
||||
|
||||
expect((await getLastResponse()).text).toBe('the answer in the server folder');
|
||||
});
|
||||
|
||||
it('never shows a conversation of the same id from ~/.claude instead', async () => {
|
||||
const home = join(testHome, '.claude');
|
||||
writeTranscript(home, 'conversation-a', 'an answer from another account');
|
||||
|
||||
// The relocated folder has conversations, but not this one.
|
||||
addPane('conversation-a', { configDir: relocated });
|
||||
writeTranscript(relocated, 'another-conversation', 'not this pane');
|
||||
expect(await getLastResponse('?context=turn')).toEqual({ text: '', timestamp: '', messages: [] });
|
||||
|
||||
// And a relocated folder that is not there at all.
|
||||
addPane('conversation-a', { configDir: join(testHome, 'not-created-yet') });
|
||||
expect(await getLastResponse()).toEqual({ text: '', timestamp: '' });
|
||||
});
|
||||
|
||||
it("follows /clear through the relocated folder's history, not the one under ~/.claude", async () => {
|
||||
const pane = addPane('before-clear', { configDir: relocated, lastSubmitAt: NOW });
|
||||
writeTranscript(relocated, 'before-clear', 'answer before clear', NOW - 60_000);
|
||||
writeTranscript(relocated, 'after-clear', 'answer after clear', NOW + 500);
|
||||
writeHistory(relocated, 'after-clear', NOW + 120);
|
||||
// The default account typed in the same folder at the same moment.
|
||||
const home = join(testHome, '.claude');
|
||||
writeTranscript(home, 'default-account', 'an answer from the default account', NOW + 500);
|
||||
writeHistory(home, 'default-account', NOW + 100);
|
||||
|
||||
expect((await getLastResponse()).text).toBe('answer after clear');
|
||||
expect(pane.adoptClaudeSessionId).toHaveBeenCalledWith('after-clear');
|
||||
});
|
||||
|
||||
it("keeps a Docker session off its override, which names a folder inside the container", async () => {
|
||||
const home = join(testHome, '.claude');
|
||||
writeTranscript(home, 'conversation-a', 'what the host folder holds');
|
||||
writeTranscript(relocated, 'conversation-a', 'a host folder that only shares the path');
|
||||
addPane('conversation-a', { configDir: relocated, docker: true });
|
||||
|
||||
expect((await getLastResponse()).text).toBe('what the host folder holds');
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user