Compare commits

..
Author SHA1 Message Date
Codeman maintainer 2a876731f6 feat(jcode): add jcode as a run mode
jcode (1jehuang/jcode) joins the CLI registry as the `jcode` run mode:
Run menu, phone overview, TUI, quick-start and POST /api/sessions
(`jcodeConfig: { model?, resumeSessionId? }`), local, SSH and Docker
(dedicated agent-image layer), plus codeman doctor and install.sh
through the generated catalogue. Measured on a live jcode 0.98.1 pane.

- Work detection reads jcode's braille status row and `<n>>` composer;
  alt screen with mouse strip and PageUp/PageDown paging like opencode.
- Every jcode TUI is a client of one shared per-user daemon whose tools
  run with the daemon's env, so no agent skill injection and no JCODE_*
  envOverrides prefix.
- New `capabilities.sharedDaemon`: a session kill spares descendants
  outside the pane's process group, so closing the tab that started the
  daemon no longer cuts every other jcode session off (measured).
- last-response answers an honest empty for an external CLI with no
  transcript reader that the pane segmenter cannot read (jcode alone).
- GET /api/jcode/status via the generic registry resolver.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-11-09 02:52:13 +01:00
85 changed files with 1531 additions and 4429 deletions

No files matched your search

+5
View File
@@ -0,0 +1,5 @@
---
"aicodeman": minor
---
**jcode as a run mode.** Codeman can now start [jcode](https://github.com/1jehuang/jcode) sessions from the Run menu, the phone overview and the API (`mode: "jcode"`, with `jcodeConfig: { model?, resumeSessionId? }`), locally, over SSH and in Docker cases (the agent image installs it). Codeman reads jcode's spinner row to tell a working session from an idle one, the wheel pages jcode's own transcript, and `codeman doctor` and the installer find it. jcode runs every session's tools in one shared background daemon, so closing a jcode tab never stops that daemon, its sessions get no Codeman agent skill and take no `JCODE_*` environment overrides, and a jcode worker's answer is read from its terminal rather than `last-response`; see `docs/jcode-integration.md`.
+4 -4
View File
@@ -58,7 +58,7 @@ When the user says **COM** (`COM` = patch, `COM minor`, `COM major`): **Read [`d
Codeman is a Claude Code session manager with web interface and autonomous Ralph Loop. Spawns Claude CLI via PTY, streams via SSE, supports respawn cycling for 24+ hour autonomous runs.
**Tech Stack**: TypeScript (ES2022/NodeNext, strict mode), Node.js, Fastify, node-pty, xterm.js. Supports Claude Code, OpenCode, Codex (OpenAI), Gemini (Google, enterprise-only since Google's June 2026 consumer cutover), Antigravity (`agy`, Google), Pi (pi.dev), Grok Build (`grok`, xAI), DeepSeek Harness (`dsh`), OMP (`omp`) and GitHub Copilot (`copilot`, npm `@github/copilot`) CLIs via pluggable CLI resolvers (`SessionMode = 'claude' | 'shell' | 'opencode' | 'codex' | 'gemini' | 'antigravity' | 'pi' | 'grok' | 'deepseek' | 'omp' | 'copilot'`).
**Tech Stack**: TypeScript (ES2022/NodeNext, strict mode), Node.js, Fastify, node-pty, xterm.js. Supports Claude Code, OpenCode, Codex (OpenAI), Gemini (Google, enterprise-only since Google's June 2026 consumer cutover), Antigravity (`agy`, Google), Pi (pi.dev), Grok Build (`grok`, xAI), DeepSeek Harness (`dsh`), OMP (`omp`), GitHub Copilot (`copilot`, npm `@github/copilot`) and jcode (`jcode`) CLIs via pluggable CLI resolvers (`SessionMode = 'claude' | 'shell' | 'opencode' | 'codex' | 'gemini' | 'antigravity' | 'pi' | 'grok' | 'deepseek' | 'omp' | 'copilot' | 'jcode'`).
**TypeScript Strictness** (see `tsconfig.json`): `noUnusedLocals`, `noUnusedParameters`, `noImplicitReturns`, `noImplicitOverride`, `noFallthroughCasesInSwitch`, `allowUnreachableCode: false`, `allowUnusedLabels: false`.
@@ -182,7 +182,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
**CLI registry** (`src/config/cli-registry/`): every run mode is a `CliEntry` (discovery, launch argv template, env handling, `capabilities`, and the `overlays` behind remote/docker pane commands). **No code outside `stock.ts` may branch on a CLI id**: use a capability field or a NAMED PROFILE (`profiles.ts`); `test/cli-registry-no-id-branching.test.ts` and `test/frontend-cli-no-id-branching.test.ts` enforce it. ⚠️ Config holds typed argv tokens, never shell text; literals are validated at LOAD time and a bad one rejects the whole entry. ⚠️ Keep `external`, `hooks` and `altScreen` independent; never derive one from another. ⚠️ Config regexes (`discovery.version.regex`, `capabilities.workDetect.workingLine`, `capabilities.workDetect.watchingLine`, `capabilities.workDetect.awaitingLine`, `capabilities.workDetect.blockedLine`, `capabilities.workDetect.composerFooterLine`, `capabilities.workDetect.composerPopupLine`, `capabilities.modelDetect.screenLine`) must compile through `compileVersionRegex()`. ⚠️ `privilegedParams[].param` names a LAUNCH PARAM, not the legacy `<Mode>Config` field (bridged only by `launch.legacyConfigAliases`); a wrong name silently clamps nothing. ⚠️ Resolve the registry AT CALL TIME, never in a module-level const. ⚠️ Remote claude/omp arms of `buildRemoteLaunchCommand` are not covered by the pane-command golden. `~/.codeman/clis.json` overrides entries. It is WRITTEN only by the opt-in CLI management routes (`cliManagementEnabled`, default OFF; `/api/clis`, `cli-registry-routes.ts`), and only through `mutateRegistryFile()` in `registry-writer.ts` (the one exception: a config-backup restore of `clis.json`, which then calls `reloadCliRegistry()`), which serializes mutations and refuses (409) a file that does not parse or has group/world permission bits rather than overwriting it. Importing the registry still writes nothing. → [architecture-invariants#cli-registry](docs/architecture-invariants.md#cli-registry), `docs/cli-registry.md`
**External CLI modes (OpenCode, Codex, Gemini, Antigravity, Pi, Grok, DeepSeek, OMP, Copilot)**: `isExternalCliMode()` in `session.ts` gates Claude-specific behavior off (Ralph tracker, BashToolParser, token parsing, ❯ readiness; readiness is output stabilization); work detection is per-CLI `capabilities.workDetect` data, not this gate. All nine **require tmux, no direct PTY fallback** (secrets go via socket-scoped `tmux setenv`, never the command line). ⚠️ `run*()` in `session-ui.js` MUST unwrap the `{success,data}` envelope. ⚠️ **Codex uses predictive write-through echo, never the buffer overlay**: `_predictHookOnData` must never `return` (wire path stays byte-identical), and flushed text and a bracketed paste must go out as separate delayed writes. ⚠️ **Pi**: no bypass flag, never invent one; `approveProjectTrust` executes repo code, so it is in the clamp's **materialize** branch; never wire `--api-key`. ⚠️ **Grok**: `alwaysApprove` is stripped for non-granted owners (only-if-sent). ⚠️ **DeepSeek**: the agent is a PROFILE (Run gates on `isDeepSeekRunnable()`); the permission switch is the `DSH_PERMISSION_MODE` env var, so `clampEnvOverridesForOwner()` must DROP `DSH_PERMISSION_MODE`, `DSH_HOME` and `DEEPSEEK_BASE_URL` for non-granted owners; `hooksAvailableForMode()` is per-SESSION for it (pass `sessionHookOptions(session)`) and is never a stand-in for `mode === 'claude'`; answers come from `deepseek-transcript.ts`, paired by header `cwd` + boot window, never newest-mtime. ⚠️ **OMP**: `OMP_AUTH_BROKER_URL`/`_TOKEN` are clamped the same way. ⚠️ **Copilot**: `allowAll` (`--yolo`) is a `privilegedParam` and `COPILOT_HOME`/`COPILOT_ALLOW_ALL`/`COPILOT_PROVIDER_*`/`COPILOT_MODEL` are privileged env keys, and so is every other `COPILOT_*` variable the CLI reads that switches approvals, runs code with no approval row or sends its sign-in token elsewhere (the 1.0.98 list is in `stock.ts`; the prefix admits whatever a later release adds, so re-read the bundle whenever the measured version moves); the resume id must start with a letter or digit (`path-segment`), since `--resume`'s value is optional and `--resume --yolo` would otherwise make `--yolo` its own flag; `--name` is dropped next to `--resume`/`--continue` (Copilot refuses the pair); its mouse DECSETs are stripped (`strip-mux-and-mouse`) so a drag selects and the wheel is forwarded by `_shouldForwardWheelToApp` while `cliMouseTracking` is on; `composerReadyMark` is the footer hint `/ commands · ? help`, NOT `❯` (the folder-trust dialog draws `❯ 1. Yes`); `last-response` reads the pane's visible screen (`transcriptNeedsScreenCapture`) through a per-CLI `PANE_DIALECTS` row, because its stream is cursor moves, not lines (a prompt row scrolled off by a tall answer reads as the answer's tail unless a banner/trust dialog is on screen, and typed composer text is not a prompt); Docker seeds no Copilot credential (`config.json` holds a plain-text token without a keyring). → [architecture-invariants#external-cli-modes-opencode-codex-gemini-antigravity-pi-grok-deepseek-omp](docs/architecture-invariants.md#external-cli-modes-opencode-codex-gemini-antigravity-pi-grok-deepseek-omp)
**External CLI modes (OpenCode, Codex, Gemini, Antigravity, Pi, Grok, DeepSeek, OMP, Copilot, jcode)**: `isExternalCliMode()` in `session.ts` gates Claude-specific behavior off (Ralph tracker, BashToolParser, token parsing, ❯ readiness; readiness is output stabilization); work detection is per-CLI `capabilities.workDetect` data, not this gate. All ten **require tmux, no direct PTY fallback** (secrets go via socket-scoped `tmux setenv`, never the command line). ⚠️ `run*()` in `session-ui.js` MUST unwrap the `{success,data}` envelope. ⚠️ **Codex uses predictive write-through echo, never the buffer overlay**: `_predictHookOnData` must never `return` (wire path stays byte-identical), and flushed text and a bracketed paste must go out as separate delayed writes. ⚠️ **Pi**: no bypass flag, never invent one; `approveProjectTrust` executes repo code, so it is in the clamp's **materialize** branch; never wire `--api-key`. ⚠️ **Grok**: `alwaysApprove` is stripped for non-granted owners (only-if-sent). ⚠️ **DeepSeek**: the agent is a PROFILE (Run gates on `isDeepSeekRunnable()`); the permission switch is the `DSH_PERMISSION_MODE` env var, so `clampEnvOverridesForOwner()` must DROP `DSH_PERMISSION_MODE`, `DSH_HOME` and `DEEPSEEK_BASE_URL` for non-granted owners; `hooksAvailableForMode()` is per-SESSION for it (pass `sessionHookOptions(session)`) and is never a stand-in for `mode === 'claude'`; answers come from `deepseek-transcript.ts`, paired by header `cwd` + boot window, never newest-mtime. ⚠️ **OMP**: `OMP_AUTH_BROKER_URL`/`_TOKEN` are clamped the same way. ⚠️ **Copilot**: `allowAll` (`--yolo`) is a `privilegedParam` and `COPILOT_HOME`/`COPILOT_ALLOW_ALL`/`COPILOT_PROVIDER_*`/`COPILOT_MODEL` are privileged env keys, and so is every other `COPILOT_*` variable the CLI reads that switches approvals, runs code with no approval row or sends its sign-in token elsewhere (the 1.0.98 list is in `stock.ts`; the prefix admits whatever a later release adds, so re-read the bundle whenever the measured version moves); the resume id must start with a letter or digit (`path-segment`), since `--resume`'s value is optional and `--resume --yolo` would otherwise make `--yolo` its own flag; `--name` is dropped next to `--resume`/`--continue` (Copilot refuses the pair); its mouse DECSETs are stripped (`strip-mux-and-mouse`) so a drag selects and the wheel is forwarded by `_shouldForwardWheelToApp` while `cliMouseTracking` is on; `composerReadyMark` is the footer hint `/ commands · ? help`, NOT `❯` (the folder-trust dialog draws `❯ 1. Yes`); `last-response` reads the pane's visible screen (`transcriptNeedsScreenCapture`) through a per-CLI `PANE_DIALECTS` row, because its stream is cursor moves, not lines (a prompt row scrolled off by a tall answer reads as the answer's tail unless a banner/trust dialog is on screen, and typed composer text is not a prompt); Docker seeds no Copilot credential (`config.json` holds a plain-text token without a keyring). ⚠️ **jcode**: every pane is a client of ONE shared per-user daemon and the tools run in the daemon with ITS env, so jcode gets no agent skill and no `JCODE_*` envOverrides prefix (a per-session value would act as another session or reach every one); `last-response` is an honest empty (external + `transcript: 'none'`, never the segmenter or the Claude scan); ⚠️ that daemon is a descendant of whichever pane started it, so `capabilities.sharedDaemon` makes `killSession` spare descendants outside the pane's process group (`descendantsInPaneGroup()`, `proc-tree.ts`), or closing that one tab cuts every other jcode session off; it resolves through the generic registry resolver, with no per-CLI resolver module (`docs/jcode-integration.md`). → [architecture-invariants#external-cli-modes-opencode-codex-gemini-antigravity-pi-grok-deepseek-omp](docs/architecture-invariants.md#external-cli-modes-opencode-codex-gemini-antigravity-pi-grok-deepseek-omp)
**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)
@@ -194,7 +194,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
**Local model recommendation** (`GET /api/local-model`, `src/local-model/`, `src/config/local-model-catalog.ts`, `src/types/local-model.ts`; EXPERIMENTAL): the server looks at the Mac it runs on and says which local coding model fits it and with which agent CLI. ⚠️ The READ side stays read-only: the probe, the catalogue and `recommend()` download nothing, install nothing and write nothing; everything that changes the machine is the install below, behind an explicit request. Apple silicon macOS only: anywhere else the answer is a `200` with verdict `none` (reasons `not-macos`, `intel`, `memory`, `disk`, `macos`, `no-harness`), and a failed probe is an answer, never a 500. The probe (`hardware.ts`) is ONE bounded async spawn of `/usr/sbin/sysctl` WITHOUT `-n`, parsed as `key: value` lines by the pure `parseSysctl()`, plus `statfs` of the data dir (probed bounded first, #516) and `node:os` fallbacks, cached 60 s; ⚠️ never `system_profiler`, never a second spawn, and `iogpu.wired_limit_mb` of 0 means not set. The catalogue is a compiled constant validated by a Zod schema: ⚠️ no override file and no runtime data, and a model id is `name:tag` in lowercase with no slash (`LOCAL_MODEL_ID_PATTERN`, so an id can never name another registry host). ⚠️ A harness is a catalogue ROW (`{ cli, minContext, rank, gated?, slowFirstTurn? }`) plus a `HarnessFact` built from the CLI registry at call time (`harness-facts.ts`); no code compares a CLI id (both id-branching guards scan these files). `recommend()` (`recommend.ts`) is pure. ⚠️ A model's need is its PEAK (`bytes + heldBytesPerToken * context + overheadBytes + peakExtraBytes`, measured on a Mac for one row and marked `estimated` on the others), and every row names its `runner` (`mlx` or `llamacpp`, the build a Mac really pulls) and carries figures of its OWN: computed from its model's config.json by that runner's rule (catalogue header, MEMORY), never borrowed from another row; `test/local-model-catalog.test.ts` recomputes them, and the `llamacpp` rows assume the runtime's `OLLAMA_KV_CACHE_TYPE=q8_0`, `LLAMA_ARG_CACHE_RAM=1024` and `LLAMA_ARG_CTX_CHECKPOINTS=8` (`runtimeEnv()`) and count what llama-server keeps beside the cache (8 context checkpoints of the recurrent or sliding-window state, and for a GGUF with a prediction layer one rollback copy of the recurrent state per drafted token and that layer's cache in f16), so change the two together. The need is judged against a budget that is `estimated` until the runtime ran and `measured` from then on (`RecommendOptions.measuredTotalBytes`). ⚠️ Ranking, lexicographic: tested on a Mac like this first, then speed `ok` before `unknown` before `slow`, then model rank, harness rank, larger context; the fit grade decides whether a row is listed and what it says, it does NOT sort, so a model is offered at the largest context it fits at. ⚠️ `limited` is a statement about the MAC (no model with 12 billion parameters or more fits), judged before the disk filter and whatever the pick is; a small pick under `ok` carries `noteSmallPick`, and a model left out for disk space is named in `noteDiskDropped`, never dropped in silence. ⚠️ The word "Recommended" exists only in `headlineTested`, and the tag "Tested on a Mac like this" only on a row whose model has a `tested` entry for exactly this chip, memory size and harness. ⚠️ Speed is `ok`/`slow`/`unknown`, graded on the unrounded figure, and a chip in no chips row is `unknown`: never guess a bandwidth. ⚠️ ONE unit for every computed size a person reads: decimal GB text from `formatGb()` (`format.ts`), sent beside the bytes, and the browser never divides bytes; the one exception is the installed memory, shown as `memClassGb` (the size the Mac is sold as, 48 and not 51.5). ⚠️ Every sentence the server chooses is a `LOCAL_MODEL_LABELS`, `LOCAL_MODEL_TAG_LABELS` or `LOCAL_MODEL_INSTALL_LABELS` text with `{placeholder}` slots filled from `vars` (no apostrophe in a text: the zh-CN key is single-quoted), and each needs a zh-CN entry. Admin only in multi-user mode (403 before anything is probed). Tests: `test/local-model-*.test.ts`, `test/routes/local-model-routes.test.ts`. Window rules: `src/web/CLAUDE.md` (Run a model on this Mac). → `docs/api-reference.md` (Local model), `docs/versioning-policy.md`
**Local model install** (`POST /api/local-model/install|use|cancel|resume|runtime`, `DELETE /api/local-model`, `DELETE /api/local-model/models`; `src/local-model/{install-job,runtime,ollama-client,connect,state-store,paths,developer-tools}.ts`; EXPERIMENTAL, admin only in multi-user mode on EVERY verb; guide `docs/local-models.md`): installs the chosen model on the Mac the server runs on and ends on a session of the coding agent running on it. ONE job of five idempotent steps (`runtime`, `model`, `verify`, `agent`, `connect`; `agent` only when the coding agent is missing), a pure reducer (`reduceInstallJob()`) over three tables in `install-job.ts` driven by one service per process (`getLocalModelService()`); the window polls the GET once a second, no SSE event. ⚠️ A LIBRARY with ONE model in use: the store may hold several models and `LocalModelStateFile.library` lists the ones that passed the check (written by the service when `verify` passes, carried by the reducer, dropped by `remove`; an `installed` file without it counts its one model, `libraryOf()`), but the endpoint record lists ONE model, the runtime holds one and only the state's model is ever launched: never widen the record to every checked model. `install` from `installed` adds a model (the earlier one stays: `replacedModelFor()` never names a library model), `use` switches to a checked one (a job of `USE_JOB_STEPS`, `verify` then `connect`; the stored digest is compared before anything is loaded, else `model-changed`), and both answer 409 `sessions-open` while a live session uses the endpoint; until such a job ends `installed`, no session starts (`launchRefusal()` answers `job-unfinished`). `DELETE /api/local-model/models` deletes ONE model by its NAME (`LOCAL_MODEL_ID_PATTERN`, never a path), only a name of the server's own listing, only through the runtime's own delete, never the model of the installation or of the current job (409 `model-active`), and it is no reducer event: its row is the `delete-model` action of `INSTALL_STATE_TABLE`. ⚠️ The view's `library` (state entries joined with the runtime's `/api/tags`: `ready`, `changed`, `missing`, `unverified`) and `store` (the blob folder's size, `measureModelStore()`) are cached (5 s, 30 s), refreshed in the background and thrown away by every verb and step that changes them (`forgetStore()`): never ask the runtime or the disk on the one-second poll, and a runtime that does not answer keeps the last list. ⚠️ Layout only through `localModelPaths()` (all `dataPath()`): `local-model/runtime/ollama-<version>/`, `local-model/models/`, `local-model/home/` (the runtime's PRIVATE HOME, so the user's `~/.ollama` is never touched) and the state file `local-model.json` (0600, ONE serialized writer, total reader, holds NO path); nothing is written or deleted outside `local-model/` except that file and the two writes of the connect step. ⚠️ The runtime pin (`OLLAMA_RUNTIME_PIN`: version, URL, size, sha256) is a COMPILED constant no file, setting or request may supply; the hash is compared BEFORE anything is unpacked. ⚠️ The runtime is a NON-detached child whose environment comes from the `runtimeEnv()` ALLOWLIST, never `process.env` (no `CODEMAN_*`, none of the user's `OLLAMA_*`); the port is chosen ONCE by binding (from 11435, never 11434) and stored; a listener on it is adopted or replaced only when its stored pid runs a binary under OUR runtime folder, anything else is `port-in-use`; started at boot when installed and enabled, max 3 restarts, SIGTERM at shutdown beside `stopDeepSeekWeb()`; under vitest nothing spawns or fetches. ⚠️ Consent is bound to the size: `acknowledgedBytes` must equal the server's own total (`Candidate.installBytes`) or the answer is 409 `size-changed`; the model, context and CLI must be a row the current recommendation offers (400). ⚠️ ONE job at a time (409 `job-running`); a job a restart interrupted comes back `paused` and NEVER continues without a click (so `shutdown()` writes nothing), and an interrupted step is never recorded as failed. ⚠️ An event the transition table does not list is refused (same object back, 409 `wrong-state`): add a row to the table, never a special case. ⚠️ The budget is MEASURED from the runtime's "inference compute" log line and the model judged against it BEFORE it is downloaded. ⚠️ The pull stream is parsed incrementally (about 1.4 million lines for 23 GB): never buffer it, never write state per line. ⚠️ A failure is a code with ONE next step (`LOCAL_MODEL_ERROR_NEXT`), never a bare "failed"; a probe that ended on its token limit is `probe-unfinished`, not `no-tool-call`. ⚠️ The coding agent is installed through the shared runner `src/cli-install-runner.ts` (extracted from `cli-registry-routes.ts`, ONE install slot per CLI for both callers) with `stockInstallCommand()`, the COMPILED stock command, never the `clis.json`-merged one (this path has no `cliManagementEnabled` gate); only for a catalogue hLine truncated
**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
**MCP server sync** (opt-in, `mcpSyncEnabled`, SYNCED, default OFF; `src/mcp-sync.ts`, `GET`/`POST /api/mcp-sync`): copies each installed, enabled CLI's user-level MCP servers into the others. It is the ONE subsystem that writes another CLI's REAL user config (`~/.claude.json`, `~/.codex/config.toml`, `~/.gemini/*`, opencode's), which is why it is opt-in and admin-only in multi-user mode (both verbs 403 for a non-admin, and the Settings group is hidden for them). Where each CLI keeps the file is registry data, `capabilities.mcpConfig` (`{ path, format, relocation? }`), never a branch on the id. ⚠️ Tools that sync but are not run modes (GitHub Copilot CLI, the first) are plain data in `MCP_SYNC_ONLY_TOOLS` (`src/mcp-sync-targets.ts`), take part only when installed or already configured, and their companion `settings.json` (`disabledMcpServers`) is read, never written; a test must clear their relocation vars too. ⚠️ ADDITIVE only: a name already defined, in any shape, is never edited or removed (a different same-name definition is a reported conflict), and a server switched off in its own CLI is never copied. ⚠️ Never write a file that did not parse; re-parse the NEW text and require every added server to read back before the tmp+rename (written through a symlink, previous file kept as `<file>.codeman-bak`, one apply at a time, else 409). ⚠️ A file that receives copied `env`/`headers` (secrets) is left `0600`, and so is the backup. ⚠️ Responses carry server NAMES only, never env values, headers or file text: a parse failure is reported by line and column (`describeMcpSyncError`), never the parser's own message (smol-toml and V8 both quote source). ⚠️ `mcpConfig.relocation` names the env var the CLI reads to move its file (`CLAUDE_CONFIG_DIR`, `CODEX_HOME`, `XDG_CONFIG_HOME`, `GEMINI_CLI_HOME`, and `COPILOT_HOME` for Copilot), resolved from the SERVER env at call time; a relative value reports the target `skipped`, never a guessed write, and a per-session `envOverrides` relocation is not followed. Tests must pass `home` (which drops the `process.env` default) or clear those vars first. → `docs/cli-registry.md` (MCP server sync), `docs/api-reference.md`, `docs/wiki/Settings-Reference.md`
@@ -328,7 +328,7 @@ One module per domain in `src/web/routes/` (plus a barrel; `ls src/web/routes/`
## State Files
All in `~/.codeman/`: `state.json` (sessions, settings, respawn, orchestrator, cron jobs/runs, owner tab layouts, case default groups), `mux-sessions.json` (tmux recovery), `settings.json` (user prefs), `push-keys.json` + `push-subscriptions.json`, `session-lifecycle.jsonl` (audit log), `update-status.json` (self-updater progress, polled across the service restart), `docker-env-applied.json` (Compose deployment only: sha256 of the Dockerfile + compose file the running container was built from, written by `Start-Codeman.sh`, read by the self-updater's environment gate), `docker-build-source.json` (Compose deployment only: the checkout's HEAD commit and `package-lock.json` hash the `codeman-node-modules`/`codeman-dist` volumes currently reflect, written by both `Start-Codeman.sh` and a successful in-place self-update, compared to detect and refresh a volume left stale by an externally-triggered rebuild), `linked-cases.json`, `webviews.json` (saved web-tab dashboard URLs), `remote-hosts.json` + `remote-cases.json`, `docker-hosts.json` + `docker-cases.json` + `docker-exports/`, `subagent-window-states.json` + `subagent-parents.json` (subagent window layout, GET/PUT `/api/subagent-window-states`/`-parents`), `hook-secret` (per-instance), `users.json` (multi-user, mode 0600) + `admin-audit.jsonl`, `intents.json` (Read My Mind intent profiles, mode 0600), `agent-inbox.json` (agent inbox snapshot, only while `agentInboxPersistEnabled` is on, mode 0600), `edit-history/<id>.json` (Edit history panel, one store per session, every diff an agent wrote, mode 0600), `webhook.json` (webhook notifications: enabled/service/scope plus the ntfy/Slack/Discord URL, a bearer secret, so mode 0600, kept out of `settings.json` and never returned by `/api/webhook`, which is admin-only in multi-user mode), `discord.json` (Discord bridge: bot token, a bearer secret, so mode 0600, never returned by `/api/discord`, admin-only in multi-user mode) + `discord-bridge.json` (sessionId to Discord thread id), `local-model.json` (local model install: state, step, model, port, pid, digest, the checked models of the library, the previous `customModelEndpointsEnabled` value; mode 0600, holds no path, not part of a config backup) + `local-model/` (the pinned runtime, the model store and the runtime's private HOME, removed by `DELETE /api/local-model`), `agent-skill-default-migrated.json` (written once by the 1.50.0 boot migration of `agentSkillEnabled`; while it exists the migration never runs again, so deleting it would switch a stored off on at the next start; never tracked or restored), `config-backup-id` (the random id config backups record as each manifest's `origin`, so instances sharing a backup folder keep to their own snapshots; mode 0600, never tracked or restored, and deleting it unlists this instance's earlier snapshots), `backups/config/` (the default config-backup folder, moved by `configBackupDir`: `cfg-YYYYMMDD-HHMMSS/` snapshots, 0700 with 0600 files, each with a `manifest.json` of SHA-256s, the origin and the retention in force (`keepCount`/`keepDays`), which a pass falls back to when `settings.json` lacks a retention key), `certs/` (self-signed TLS for `--https`), `.env` (CODEMAN_USERNAME/PASSWORD fallback for the `codeman attach` CLI), `install.log` (installer step output, written by `install.sh`'s `run_step`) `tailscale-rename` (the node name before `install.sh` renamed it, so uninstall can offer it back), `cli-installed` (empty, touched by `install.sh` when it installs a CLI: a running server reads only its mtime, and a change ends that CLI's negative-cache backoff) and `runtime/` (the Node.js and tmux `install.sh` downloaded on its no-admin route, linked from `~/.local/bin`; all four installer-route only). Transient: `self-update-runner.sh`. Multi-user case spaces live OUTSIDE the data dir at `~/codeman-users/<username>/cases` (shared across instances like `~/codeman-cases`, override `CODEMAN_USER_SPACES_DIR`).
All in `~/.codeman/`: `state.json` (sessions, settings, respawn, orchestrator, cron jobs/runs, owner tab layouts, case default groups), `mux-sessions.json` (tmux recovery), `settings.json` (user prefs), `push-keys.json` + `push-subscriptions.json`, `session-lifecycle.jsonl` (audit log), `update-status.json` (self-updater progress, polled across the service restart), `docker-env-applied.json` (Compose deployment only: sha256 of the Dockerfile + compose file the running container was built from, written by `Start-Codeman.sh`, read by the self-updater's environment gate), `docker-build-source.json` (Compose deployment only: the checkout's HEAD commit and `package-lock.json` hash the `codeman-node-modules`/`codeman-dist` volumes currently reflect, written by both `Start-Codeman.sh` and a successful in-place self-update, compared to detect and refresh a volume left stale by an externally-triggered rebuild), `linked-cases.json`, `webviews.json` (saved web-tab dashboard URLs), `remote-hosts.json` + `remote-cases.json`, `docker-hosts.json` + `docker-cases.json` + `docker-exports/`, `subagent-window-states.json` + `subagent-parents.json` (subagent window layout, GET/PUT `/api/subagent-window-states`/`-parents`), `hook-secret` (per-instance), `users.json` (multi-user, mode 0600) + `admin-audit.jsonl`, `intents.json` (Read My Mind intent profiles, mode 0600), `agent-inbox.json` (agent inbox snapshot, only while `agentInboxPersistEnabled` is on, mode 0600), `edit-history/<id>.json` (Edit history panel, one store per session, every diff an agent wrote, mode 0600), `webhook.json` (webhook notifications: enabled/service/scope plus the ntfy/Slack/Discord URL, a bearer secret, so mode 0600, kept out of `settings.json` and never returned by `/api/webhook`, which is admin-only in multi-user mode), `discord.json` (Discord bridge: bot token, a bearer secret, so mode 0600, never returned by `/api/discord`, admin-only in multi-user mode) + `discord-bridge.json` (sessionId to Discord thread id), `local-model.json` (local model install: state, step, model, port, pid, digest, the previous `customModelEndpointsEnabled` value; mode 0600, holds no path, not part of a config backup) + `local-model/` (the pinned runtime, the model store and the runtime's private HOME, removed by `DELETE /api/local-model`), `agent-skill-default-migrated.json` (written once by the 1.50.0 boot migration of `agentSkillEnabled`; while it exists the migration never runs again, so deleting it would switch a stored off on at the next start; never tracked or restored), `config-backup-id` (the random id config backups record as each manifest's `origin`, so instances sharing a backup folder keep to their own snapshots; mode 0600, never tracked or restored, and deleting it unlists this instance's earlier snapshots), `backups/config/` (the default config-backup folder, moved by `configBackupDir`: `cfg-YYYYMMDD-HHMMSS/` snapshots, 0700 with 0600 files, each with a `manifest.json` of SHA-256s, the origin and the retention in force (`keepCount`/`keepDays`), which a pass falls back to when `settings.json` lacks a retention key), `certs/` (self-signed TLS for `--https`), `.env` (CODEMAN_USERNAME/PASSWORD fallback for the `codeman attach` CLI), `install.log` (installer step output, written by `install.sh`'s `run_step`) `tailscale-rename` (the node name before `install.sh` renamed it, so uninstall can offer it back), `cli-installed` (empty, touched by `install.sh` when it installs a CLI: a running server reads only its mtime, and a change ends that CLI's negative-cache backoff) and `runtime/` (the Node.js and tmux `install.sh` downloaded on its no-admin route, linked from `~/.local/bin`; all four installer-route only). Transient: `self-update-runner.sh`. Multi-user case spaces live OUTSIDE the data dir at `~/codeman-users/<username>/cases` (shared across instances like `~/codeman-cases`, override `CODEMAN_USER_SPACES_DIR`).
**Generated top-level dirs** (all gitignored — don't edit or commit): `dist/` (esbuild output), `out/`, `coverage/`, `test-results/`, `tmp/`, `screenshots-echo-diag/`. The committed gesture bundle (`src/web/public/gesture/gesture-codeman.js`) IS tracked, but its runtime wasm/model assets (`src/web/public/gesture/wasm/`, `*.task`) are fetched and gitignored.
+8 -8
View File
@@ -5,7 +5,7 @@
<h2 align="center">Mission control for AI coding agents</h2>
<p align="center">
<em>Claude Code &bull; OpenCode &bull; Codex &bull; Antigravity &bull; Gemini &bull; Pi &bull; Grok &bull; DeepSeek &bull; OMP &bull; Copilot &bull; Terminal - One Dashboard &bull; Any Device</em>
<em>Claude Code &bull; OpenCode &bull; Codex &bull; Antigravity &bull; Gemini &bull; Pi &bull; Grok &bull; DeepSeek &bull; OMP &bull; Copilot &bull; jcode &bull; Terminal - One Dashboard &bull; Any Device</em>
</p>
<p align="center">
@@ -33,7 +33,7 @@
<img src="docs/images/tiles-crt-stats-1600-20261010.gif" alt="Codeman tile grid: six live agents (DeepSeek Harness, Claude Code, Pi, Codex, OpenCode and a shell) powering on and off with the CRT animation, with the live header strip showing CPU, memory and Claude plan usage" width="800">
</p>
**Codeman** is a self-hosted mission control for AI coding agents. It spawns Claude Code, OpenCode, Codex, Antigravity, Gemini, Pi, Grok, DeepSeek Harness, OMP, or GitHub Copilot CLI inside persistent tmux sessions, streams the real terminal to any browser, and keeps agents productive after you walk away: it re-prompts on idle, resumes when a usage limit resets, runs scheduled jobs, and shows every background agent working in real time.
**Codeman** is a self-hosted mission control for AI coding agents. It spawns Claude Code, OpenCode, Codex, Antigravity, Gemini, Pi, Grok, DeepSeek Harness, OMP, GitHub Copilot CLI, or jcode inside persistent tmux sessions, streams the real terminal to any browser, and keeps agents productive after you walk away: it re-prompts on idle, resumes when a usage limit resets, runs scheduled jobs, and shows every background agent working in real time.
Get started in one line (macOS & Linux, Windows via WSL):
@@ -50,7 +50,7 @@ The installer asks before every system change, and re-running the same line upda
On a Mac you can skip the terminal: [download Codeman.dmg](https://github.com/Ark0N/Codeman/releases/latest/download/Codeman.dmg), and the app installs Codeman for you, opens the one already running, or connects to one on another machine. More in [On a Mac: the Codeman app](#on-a-mac-the-codeman-app).
- **One dashboard, ten CLIs** - run [Claude Code, OpenCode, Codex, Antigravity, Gemini, Pi, Grok, DeepSeek, OMP, or GitHub Copilot](#more-features) per session (plus plain shell), locally, [in Docker](#isolated-docker-sessions), or [over SSH](#remote-ssh-sessions), with your own dashboards open as [web tabs](#more-features) beside them
- **One dashboard, eleven CLIs** - run [Claude Code, OpenCode, Codex, Antigravity, Gemini, Pi, Grok, DeepSeek, OMP, GitHub Copilot, or jcode](#more-features) per session (plus plain shell), locally, [in Docker](#isolated-docker-sessions), or [over SSH](#remote-ssh-sessions), with your own dashboards open as [web tabs](#more-features) beside them
- **Truly phone-friendly** - a [touch-optimized terminal](#mobile-optimized-web-ui) with instant local echo, QR login, swipe navigation, and push notifications
- **Runs while you sleep** - [idle detection + respawn cycling](#respawn-controller) and auto-resume when a subscription limit resets, for 24+ hour unattended runs
- **See your agents think** - [live floating windows](#live-agent-visualization) for every subagent and teammate, with real-time transcripts
@@ -79,7 +79,7 @@ This installs Node.js, tmux and a build toolchain if missing (node-pty ships no
- **No administrator rights needed.** On an account without `sudo`, one whose `sudo` rights the installer cannot confirm (outside the `sudo`, `wheel` and `admin` groups, with a password still required) or a Mac account that is not an administrator and has no Homebrew it can write to, the installer downloads Node.js from nodejs.org and the tmux project's own build into `~/.codeman/runtime`, each checked against its SHA-256, and takes the source as an archive when there is no working git. `--no-admin` forces that route and `CODEMAN_NO_ADMIN=0` turns it off. Two limits: Linux needs make, a C++ compiler and python3 to be there already, and the tmux download needs macOS 15 or newer. Details in the [wiki](https://github.com/Ark0N/Codeman/wiki/Installation#without-administrator-rights).
- **Flags for the impatient.** `curl -fsSL https://getcodeman.com/install | bash -s -- --tailscale --service` answers the questions from the command line (`--lan`, `--local`, `--run`, `--no-start`, `--name <n>`, `--port <n>`, `--yes` too). **CI / headless:** without a terminal attached, steps that would change your system abort with instructions instead of running silently; set `CODEMAN_NONINTERACTIVE=1` to approve them for automation.
You'll need at least one AI coding CLI installed — [Claude Code](https://docs.anthropic.com/en/docs/claude-code), [OpenCode](https://opencode.ai), [Codex](https://developers.openai.com/codex/cli), [Antigravity](https://antigravity.google), [Gemini CLI](https://github.com/google-gemini/gemini-cli), [Pi](https://pi.dev), [Grok Build](https://github.com/xai-org/grok-build), [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness), [OMP](https://github.com/can1357/oh-my-pi), or [GitHub Copilot CLI](https://github.com/github/copilot-cli) (any combination works; Gemini CLI is enterprise-only since Google's consumer cutover, and Antigravity is its successor). The installer detects whichever of the ten is present; if none is found, it offers to install any of them from a menu (DeepSeek excepted, since its npm package installs only a launcher with no runnable profile), or you can skip and install one yourself later. After install:
You'll need at least one AI coding CLI installed — [Claude Code](https://docs.anthropic.com/en/docs/claude-code), [OpenCode](https://opencode.ai), [Codex](https://developers.openai.com/codex/cli), [Antigravity](https://antigravity.google), [Gemini CLI](https://github.com/google-gemini/gemini-cli), [Pi](https://pi.dev), [Grok Build](https://github.com/xai-org/grok-build), [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness), [OMP](https://github.com/can1357/oh-my-pi), [GitHub Copilot CLI](https://github.com/github/copilot-cli), or [jcode](https://jcode.sh) (any combination works; Gemini CLI is enterprise-only since Google's consumer cutover, and Antigravity is its successor). The installer detects whichever of the eleven is present; if none is found, it offers to install any of them from a menu (DeepSeek excepted, since its npm package installs only a launcher with no runnable profile), or you can skip and install one yourself later. After install:
```bash
codeman web
@@ -290,7 +290,7 @@ Click **+ New Session** (or **Quick Start**). A session is one AI CLI running in
| Field | What it does |
| ---------------------------- | ------------------------------------------------------------------------------------------------------------------- |
| **Working directory / case** | The folder the agent operates in. A "case" is just a named working dir Codeman remembers. **Add Case** creates one from scratch, links an existing folder, or clones a GitHub repo straight into one (**Clone Repo**). |
| **CLI / run mode** | `Claude` (default), `OpenCode`, `Codex`, `Antigravity`, `Gemini`, `Pi`, `Grok`, `DeepSeek`, `OMP`, `Copilot`, or `Terminal` (plain shell). |
| **CLI / run mode** | `Claude` (default), `OpenCode`, `Codex`, `Antigravity`, `Gemini`, `Pi`, `Grok`, `DeepSeek`, `OMP`, `Copilot`, `jcode`, or `Terminal` (plain shell). |
| **Model** | Per-session model (App Settings → Models → New Claude sessions). A soft default — `/model` still works in-session. |
| **Effort / Ultracode** | Reasoning effort (`low`–`max`) or `ultracode` for dynamic multi-agent workflows. Switchable anytime with `/effort`. |
@@ -495,7 +495,7 @@ PTY Output → 16ms Server Batch → DEC 2026 Wrap → SSE → Client rAF → xt
- **Clone a GitHub repo as a case** — paste a repository URL into **Add Case → Clone Repo** and Codeman clones it into `~/codeman-cases/<name>` and registers it as a normal case, ready to run an agent in. It preflights the URL while you type (tells you whether it can be cloned anonymously and offers the repo's real branches and tags for the optional branch/tag field), fills the case name in from the URL, and lets you pick which CLI the Run button should use. Public repositories over `https://`; Codeman never collects or stores credentials
- **Create a case in a custom folder** — tick **Create in a custom folder** in **Add Case → Create New**, pick a parent folder (Browse included) and a name, and Codeman scaffolds the new case there instead of `~/codeman-cases`. The target must be a new or empty folder; system directories, your home folder itself, credential trees such as `~/.ssh`, and Codeman's own data folder are refused. Admin only in multi-user mode.
- **Open a tmux session you started by hand**: a tmux session started outside Codeman shows up on the home screen, on this machine automatically and in your Docker cases' containers or on your saved SSH hosts when you scan for them. One click opens it as a tab. Closing the tab only detaches, and Codeman will not respawn or relaunch an agent it did not start. See [Opened outside Codeman](docs/wiki/The-Dashboard.md#opened-outside-codeman).
- **Multi-CLI** — run **Claude Code**, **OpenCode**, **Codex**, **Antigravity**, **Gemini**, **Pi**, **Grok**, **DeepSeek Harness**, **OMP**, or **GitHub Copilot CLI** per session; env-var prefixes auto-gate (`CLAUDE_CODE_*` vs `OPENCODE_*` vs `CODEX_*` vs `ANTIGRAVITY_*` vs `GEMINI_*`/`GOOGLE_*` vs `PI_*` vs `GROK_*`/`XAI_*` vs `DSH_*`/`DEEPSEEK_*` vs `OMP_*` vs `COPILOT_*`). See [`docs/opencode-integration.md`](docs/opencode-integration.md), [`docs/pi-integration.md`](docs/pi-integration.md), [`docs/grok-integration.md`](docs/grok-integration.md), [`docs/deepseek-integration.md`](docs/deepseek-integration.md) [`docs/omp-integration.md`](docs/omp-integration.md) and [`docs/copilot-integration.md`](docs/copilot-integration.md)
- **Multi-CLI** — run **Claude Code**, **OpenCode**, **Codex**, **Antigravity**, **Gemini**, **Pi**, **Grok**, **DeepSeek Harness**, **OMP**, **GitHub Copilot CLI**, or **jcode** per session; env-var prefixes auto-gate (`CLAUDE_CODE_*` vs `OPENCODE_*` vs `CODEX_*` vs `ANTIGRAVITY_*` vs `GEMINI_*`/`GOOGLE_*` vs `PI_*` vs `GROK_*`/`XAI_*` vs `DSH_*`/`DEEPSEEK_*` vs `OMP_*` vs `COPILOT_*`). See [`docs/opencode-integration.md`](docs/opencode-integration.md), [`docs/pi-integration.md`](docs/pi-integration.md), [`docs/grok-integration.md`](docs/grok-integration.md), [`docs/deepseek-integration.md`](docs/deepseek-integration.md), [`docs/omp-integration.md`](docs/omp-integration.md), [`docs/copilot-integration.md`](docs/copilot-integration.md) and [`docs/jcode-integration.md`](docs/jcode-integration.md) (jcode takes no env prefix: its tools run in one shared daemon)
- **Custom model endpoints** _(new in 1.29.0, HTTP API for now)_ — point a session's CLI at any OpenAI-compatible endpoint instead of its native backend: a local llama.cpp, llama-swap, Ollama or vLLM box, or a cloud gateway such as Azure AI Foundry or OpenRouter. Save an endpoint once (`POST /api/model-endpoints`; its models are discovered from `/v1/models`), apply it to a session (`POST /api/sessions/:id/custom-model`), and the CLI restarts in place on that endpoint. Verified live for Claude, OpenCode, Pi, Grok and OMP; Codex, Gemini and DeepSeek have documented gaps, Antigravity has no mechanism. A toolbar picker is the follow-up. See [`docs/custom-model-endpoints.md`](docs/custom-model-endpoints.md)
- **A model on this Mac** _(experimental, new in 1.50.0)_: on a Mac with Apple silicon, **Run a local model** on the welcome screen shows which coding model fits, installs it with its runtime and a coding agent, and starts a session on it: no account, no API key, and the prompts and files of that session go to a model on this Mac, not to a cloud provider. Then mix cloud and local: through the [codeman skill](#the-agent-skill-start-here) a Claude Code session hands work to that model (`spawn_worker <name> local`, `sendmark`) and reads the answer back, so Claude plans and reviews in the cloud while the Mac does the bulk: summaries, first drafts, files that should stay here. See [`docs/local-models.md`](docs/local-models.md)
- **Web tabs** — open Grafana, Uptime Kuma, a Vite dev server or any dashboard URL as a tab beside your sessions (Run dropdown → **Web / URL** → **Add URL**). Dashboards are proxied through Codeman's own origin, so an `http://` target works from a phone over HTTPS and through the tunnel, single-page apps route on their own paths, and a frame that reloads recovers itself. A `localhost` link an agent prints opens as a web tab automatically. See [`docs/web-tabs.md`](docs/web-tabs.md)
@@ -977,7 +977,7 @@ for _ in $(seq 1 10); do
done
printf '%s\n' "$TXT"
# 5b. Other modes (shell/opencode/gemini/antigravity/pi/grok/omp/copilot) have no transcript:
# 5b. Other modes (shell/opencode/gemini/antigravity/pi/grok/omp/copilot/jcode) have no transcript:
# read the terminal. ⚠️ Use terminal?tail=, NOT /output: the latter's textOutput
# is empty for every tmux-backed (i.e. every interactive) session. tail counts
# BYTES, and what comes back is terminal data, ANSI included.
@@ -1218,7 +1218,7 @@ flowchart TB
end
subgraph External["External"]
CLI["AI CLI<br/><small>Claude Code / OpenCode / Codex / Antigravity / Gemini / Pi / Grok / DeepSeek / OMP / GitHub Copilot</small>"]
CLI["AI CLI<br/><small>Claude Code / OpenCode / Codex / Antigravity / Gemini / Pi / Grok / DeepSeek / OMP / GitHub Copilot / jcode</small>"]
BG["Background Agents<br/><small>(Task tool)</small>"]
end
end
+8 -8
View File
@@ -5,7 +5,7 @@
<h2 align="center">AI 编程智能体的任务控制中心</h2>
<p align="center">
<em>Claude Code &bull; OpenCode &bull; Codex &bull; Antigravity &bull; Gemini &bull; Pi &bull; Grok &bull; DeepSeek &bull; OMP &bull; Copilot &bull; 终端 —— 统一仪表盘 &bull; 任意设备</em>
<em>Claude Code &bull; OpenCode &bull; Codex &bull; Antigravity &bull; Gemini &bull; Pi &bull; Grok &bull; DeepSeek &bull; OMP &bull; Copilot &bull; jcode &bull; 终端 —— 统一仪表盘 &bull; 任意设备</em>
</p>
<p align="center">
@@ -35,7 +35,7 @@
> 本文档由英文版 [`README.md`](README.md) 翻译而来。如有出入,以英文版为准。
**Codeman** 是一个自托管的 AI 编程智能体任务控制中心。它在持久化的 tmux 会话里拉起 Claude Code、OpenCode、Codex、Antigravity、Gemini、Pi、Grok、DeepSeek Harness、OMP 或 GitHub Copilot CLI,把真实的终端流式传到任意浏览器,并在你离开之后让智能体继续干活:空闲时重新提示、用量限额重置后自动续跑、按计划执行任务,还能实时展示每一个后台智能体的工作。
**Codeman** 是一个自托管的 AI 编程智能体任务控制中心。它在持久化的 tmux 会话里拉起 Claude Code、OpenCode、Codex、Antigravity、Gemini、Pi、Grok、DeepSeek Harness、OMP、GitHub Copilot CLI 或 jcode,把真实的终端流式传到任意浏览器,并在你离开之后让智能体继续干活:空闲时重新提示、用量限额重置后自动续跑、按计划执行任务,还能实时展示每一个后台智能体的工作。
一行命令即可安装(macOS 和 Linux,Windows 通过 WSL):
@@ -52,7 +52,7 @@ codeman web
在 Mac 上可以不用终端:[下载 Codeman.dmg](https://github.com/Ark0N/Codeman/releases/latest/download/Codeman.dmg),这个应用会替你安装 Codeman、打开已在运行的 Codeman,或者连接另一台机器上的 Codeman。详见[在 Mac 上:Codeman 应用](#在-mac-上codeman-应用)。
- **一个仪表盘,十个 CLI**:每个会话可选 [Claude Code、OpenCode、Codex、Antigravity、Gemini、Pi、Grok、DeepSeek、OMP 或 GitHub Copilot](#更多特性)(外加普通 shell),在本机、[Docker 容器](#隔离的-docker-会话)或 [SSH 远程主机](#远程-ssh-会话)上运行,你自己的仪表盘也能作为 [Web 标签页](#更多特性)并排打开
- **一个仪表盘,十一个 CLI**:每个会话可选 [Claude Code、OpenCode、Codex、Antigravity、Gemini、Pi、Grok、DeepSeek、OMP、GitHub Copilot 或 jcode](#更多特性)(外加普通 shell),在本机、[Docker 容器](#隔离的-docker-会话)或 [SSH 远程主机](#远程-ssh-会话)上运行,你自己的仪表盘也能作为 [Web 标签页](#更多特性)并排打开
- **真正的手机友好**:[触控优化的终端](#移动端优化的-web-ui),即时本地回显、二维码登录、滑动导航与推送通知
- **睡觉时也在跑**:[空闲检测 + 重生循环](#重生控制器respawn-controller),订阅限额重置后自动续跑,支持 24 小时以上的无人值守运行
- **看见智能体在想什么**:每个子智能体和团队成员都有[实时浮动窗口](#实时智能体可视化),附带实时活动记录
@@ -81,7 +81,7 @@ curl -fsSL https://getcodeman.com/install | bash
- **无需管理员权限。** 在没有 `sudo` 的账户、安装器无法确认 `sudo` 权限的账户(不在 `sudo`、`wheel`、`admin` 组内,且 `sudo` 需要密码)或不是管理员、也没有可写入的 Homebrew 的 Mac 账户上,安装器会把 nodejs.org 的 Node.js 和 tmux 项目自己的构建下载到 `~/.codeman/runtime`,两者都会校验 SHA-256;没有可用的 git 时,则以归档包的方式获取源码。`--no-admin` 强制走这条路线,`CODEMAN_NO_ADMIN=0` 则将其关闭。两个限制:Linux 上必须已有 make、C++ 编译器和 python3,tmux 下载需要 macOS 15 或更新版本。详见 [wiki](https://github.com/Ark0N/Codeman/wiki/Installation#without-administrator-rights)。
- **给急性子的参数。** `curl -fsSL https://getcodeman.com/install | bash -s -- --tailscale --service` 直接在命令行上回答这些问题(还有 `--lan`、`--local`、`--run`、`--no-start`、`--name <n>`、`--port <n>`、`--yes`)。**CI / 无终端环境:** 没有终端时,涉及系统改动的步骤会带着说明中止,而不是静默执行;在自动化场景设置 `CODEMAN_NONINTERACTIVE=1` 即可批准这些步骤。
你至少需要安装一个 AI 编程 CLI —— [Claude Code](https://docs.anthropic.com/en/docs/claude-code)、[OpenCode](https://opencode.ai)、[Codex](https://developers.openai.com/codex/cli)、[Antigravity](https://antigravity.google)、[Gemini CLI](https://github.com/google-gemini/gemini-cli)、[Pi](https://pi.dev)、[Grok Build](https://github.com/xai-org/grok-build)、[DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness)、[OMP](https://github.com/can1357/oh-my-pi) 或 [GitHub Copilot CLI](https://github.com/github/copilot-cli)(任意组合均可;自 Google 面向消费者停售后,Gemini CLI 仅限企业版,Antigravity 是其继任者)。安装器会自动检测这十个中已安装的任意一个;若一个都没有,会给出一个菜单让你安装其中任意一个(DeepSeek 除外,它的 npm 包只装一个启动器,没有可运行的 profile),也可以选择跳过、稍后自行安装。安装完成后:
你至少需要安装一个 AI 编程 CLI —— [Claude Code](https://docs.anthropic.com/en/docs/claude-code)、[OpenCode](https://opencode.ai)、[Codex](https://developers.openai.com/codex/cli)、[Antigravity](https://antigravity.google)、[Gemini CLI](https://github.com/google-gemini/gemini-cli)、[Pi](https://pi.dev)、[Grok Build](https://github.com/xai-org/grok-build)、[DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness)、[OMP](https://github.com/can1357/oh-my-pi)、[GitHub Copilot CLI](https://github.com/github/copilot-cli) 或 [jcode](https://jcode.sh)(任意组合均可;自 Google 面向消费者停售后,Gemini CLI 仅限企业版,Antigravity 是其继任者)。安装器会自动检测这十一个中已安装的任意一个;若一个都没有,会给出一个菜单让你安装其中任意一个(DeepSeek 除外,它的 npm 包只装一个启动器,没有可运行的 profile),也可以选择跳过、稍后自行安装。安装完成后:
```bash
codeman web
@@ -292,7 +292,7 @@ codeman service install # systemd/launchd 服务:重启后自动回
| 字段 | 作用 |
| ---------------------- | ------------------------------------------------------------------------------------------- |
| **工作目录 / case** | 智能体操作的文件夹。「case」就是一个 Codeman 记住的命名工作目录。**Add Case** 可以从零创建、链接一个已有文件夹,或把一个 GitHub 仓库直接克隆成 case(**Clone Repo**)。 |
| **CLI / 运行模式** | `Claude`(默认)、`OpenCode`、`Codex`、`Antigravity`、`Gemini`、`Pi`、`Grok`、`DeepSeek`、`OMP`、`Copilot` 或 `Terminal`(普通 shell)。 |
| **CLI / 运行模式** | `Claude`(默认)、`OpenCode`、`Codex`、`Antigravity`、`Gemini`、`Pi`、`Grok`、`DeepSeek`、`OMP`、`Copilot`、`jcode` 或 `Terminal`(普通 shell)。 |
| **模型** | 每会话模型(App Settings → Models → New Claude sessions)。软默认值 —— 会话内 `/model` 依然有效。 |
| **Effort / Ultracode** | 推理力度(`low`–`max`),或用 `ultracode` 开启动态多智能体工作流。随时可用 `/effort` 切换。 |
@@ -497,7 +497,7 @@ PTY 输出 → 16ms 服务端批处理 → DEC 2026 包裹 → SSE → 客户端
- **把 GitHub 仓库克隆成 case** —— 在 **Add Case → Clone Repo** 里粘贴一个仓库 URL,Codeman 会把它克隆到 `~/codeman-cases/<name>` 并注册为普通 case,随时可以跑智能体。输入时它会预检 URL(告诉你能否匿名克隆,并为可选的分支/标签字段提供仓库真实的分支与标签),从 URL 里填好 case 名,还让你选 Run 按钮该用哪个 CLI。支持 `https://` 的公开仓库;Codeman 绝不收集或保存凭据
- **在自定义文件夹里创建 case**:在 **Add Case → Create New** 里勾选 **Create in a custom folder**,选一个父文件夹(可用 Browse 浏览)和一个名字,Codeman 就会在那里而不是 `~/codeman-cases` 里搭建新 case。目标必须是一个新的或空的文件夹;系统目录、你的主目录本身、`~/.ssh` 这类凭据目录,以及 Codeman 自己的数据目录都会被拒绝。多用户模式下仅管理员可用
- **打开你手动启动的 tmux 会话**:在 Codeman 之外启动的 tmux 会话会出现在主屏幕上:本机的会自动列出,Docker case 容器内和已保存 SSH 主机上的会在你扫描时列出。点一下即可作为标签页打开。关闭标签页只会分离,Codeman 不会重生或重启并非由它启动的智能体。详见 [Opened outside Codeman](docs/wiki/The-Dashboard.md#opened-outside-codeman)。
- **多 CLI** —— 每个会话可选 **Claude Code**、**OpenCode**、**Codex**、**Antigravity**、**Gemini**、**Pi**、**Grok**、**DeepSeek Harness**、**OMP** 或 **GitHub Copilot CLI**;环境变量前缀自动隔离(`CLAUDE_CODE_*`、`OPENCODE_*`、`CODEX_*`、`ANTIGRAVITY_*`、`GEMINI_*`/`GOOGLE_*`、`PI_*`、`GROK_*`/`XAI_*`、`DSH_*`/`DEEPSEEK_*`、`OMP_*` 与 `COPILOT_*`)。详见 [`docs/opencode-integration.md`](docs/opencode-integration.md)、[`docs/pi-integration.md`](docs/pi-integration.md)、[`docs/grok-integration.md`](docs/grok-integration.md)、[`docs/deepseek-integration.md`](docs/deepseek-integration.md)、[`docs/omp-integration.md`](docs/omp-integration.md) 与 [`docs/copilot-integration.md`](docs/copilot-integration.md)
- **多 CLI** —— 每个会话可选 **Claude Code**、**OpenCode**、**Codex**、**Antigravity**、**Gemini**、**Pi**、**Grok**、**DeepSeek Harness**、**OMP**、**GitHub Copilot CLI** 或 **jcode**;环境变量前缀自动隔离(`CLAUDE_CODE_*`、`OPENCODE_*`、`CODEX_*`、`ANTIGRAVITY_*`、`GEMINI_*`/`GOOGLE_*`、`PI_*`、`GROK_*`/`XAI_*`、`DSH_*`/`DEEPSEEK_*`、`OMP_*` 与 `COPILOT_*`)。详见 [`docs/opencode-integration.md`](docs/opencode-integration.md)、[`docs/pi-integration.md`](docs/pi-integration.md)、[`docs/grok-integration.md`](docs/grok-integration.md)、[`docs/deepseek-integration.md`](docs/deepseek-integration.md)、[`docs/omp-integration.md`](docs/omp-integration.md)、[`docs/copilot-integration.md`](docs/copilot-integration.md) 与 [`docs/jcode-integration.md`](docs/jcode-integration.md)(jcode 不接受环境变量前缀:它的工具运行在一个共享的守护进程里)
- **自定义模型端点**(1.29.0 新增,目前仅 HTTP API)—— 让某个会话的 CLI 指向任意 OpenAI 兼容端点,而不是它自己的官方后端:本地的 llama.cpp、llama-swap、Ollama 或 vLLM 机器,也可以是 Azure AI Foundry、OpenRouter 这类云端网关。端点只需保存一次(`POST /api/model-endpoints`,模型列表从它的 `/v1/models` 自动发现),再应用到会话(`POST /api/sessions/:id/custom-model`),CLI 就会在原地重启并接上该端点。Claude、OpenCode、Pi、Grok 与 OMP 已实测通过;Codex、Gemini 与 DeepSeek 存在已记录的缺口,Antigravity 没有可用机制。工具栏选择器是下一步。详见 [`docs/custom-model-endpoints.md`](docs/custom-model-endpoints.md)
- **Web 标签页** —— 把 Grafana、Uptime Kuma、一个 Vite 开发服务器或任何仪表盘 URL 作为标签页打开在会话旁边(Run 下拉菜单 → **Web / URL** → **Add URL**)。仪表盘通过 Codeman 自己的源代理,因此 `http://` 目标在手机上走 HTTPS 也能用、走隧道也能用;单页应用能在自己的路径上正常路由,页面自己重载后也能自行恢复。智能体打印出的 `localhost` 链接会自动以 Web 标签页打开。详见 [`docs/web-tabs.md`](docs/web-tabs.md)
- **Docker 会话** —— 在隔离且加固的容器中运行 case。**Create New** 上勾选一个复选框即可用合理的默认值启动容器并在其中启动智能体;同一 case 的多个会话共享一个容器,也可以把 case 挂到你已经在跑的容器上;可将容器连同工作区导出为可移植的 `.tar.gz`,迁移到另一台机器。详见 [`docs/docker-cases.md`](docs/docker-cases.md)
@@ -971,7 +971,7 @@ for _ in $(seq 1 10); do
done
printf '%s\n' "$TXT"
# 5b. 其他模式(shell/opencode/gemini/antigravity/pi/grok/omp/copilot)没有 transcript,读终端。
# 5b. 其他模式(shell/opencode/gemini/antigravity/pi/grok/omp/copilot/jcode)没有 transcript,读终端。
# ⚠️ 用 terminal?tail=,不要用 /output:后者的 textOutput 对每个由 tmux 承载的
# (也就是每个交互式)会话都是空的。tail 按字节计,返回的是含 ANSI 的终端数据。
curl -s "$API/api/sessions/$SID/terminal?tail=8000" | jq -r '.data.terminalBuffer'
@@ -1192,7 +1192,7 @@ flowchart TB
end
subgraph External["外部"]
CLI["AI CLI<br/><small>Claude Code / OpenCode / Codex / Antigravity / Gemini / Pi / Grok / DeepSeek / OMP / GitHub Copilot</small>"]
CLI["AI CLI<br/><small>Claude Code / OpenCode / Codex / Antigravity / Gemini / Pi / Grok / DeepSeek / OMP / GitHub Copilot / jcode</small>"]
BG["后台智能体<br/><small>(Task 工具)</small>"]
end
end
+29
View File
@@ -305,5 +305,34 @@
"docsUrl": "https://github.com/github/copilot-cli"
}
}
},
{
"id": "jcode",
"label": "jcode",
"shortBadge": "JC",
"enabled": true,
"order": 110,
"kind": "agent",
"discovery": {
"binaries": [
"jcode"
],
"searchDirs": [
"~/.local/bin",
"/usr/local/bin",
"~/bin"
],
"install": {
"command": {
"linux": "curl -fsSL https://jcode.sh/install | bash",
"darwin": "curl -fsSL https://jcode.sh/install | bash"
},
"docsUrl": "https://jcode.sh/docs",
"agentImageLayer": {
"kind": "dedicated",
"reason": "Not on npm: jcode ships a standalone Rust binary through jcode.sh/install, whose launcher is a script that execs the binary beside it."
}
}
}
}
]
+15
View File
@@ -203,6 +203,21 @@ RUN curl -fsSL https://omp.sh/install | sh \
&& rm -f /root/.local/bin/omp \
&& omp --version
# jcode is NOT on npm: a standalone Rust binary via jcode.sh's installer, which
# writes $HOME/.jcode/builds/versions/<v>/ and links $HOME/.local/bin/jcode to it.
# The linked `jcode` is a small sh LAUNCHER that resolves its own path and execs
# the `jcode-<os>-<arch>.bin` beside it, so (unlike grok/omp above) the whole
# version directory is copied, to /opt/jcode, and /usr/local/bin/jcode links the
# launcher there. Root's ~/.jcode is dropped in the same layer so the image does
# not carry the ~160MB binary twice. JCODE_SKIP_SERVER_RELOAD: there is no jcode
# daemon at build time to hand off to.
RUN curl -fsSL https://jcode.sh/install | JCODE_SKIP_SERVER_RELOAD=1 bash \
&& mkdir -p /opt/jcode \
&& cp -a "$(dirname "$(readlink -f /root/.local/bin/jcode)")/." /opt/jcode/ \
&& ln -sfn /opt/jcode/jcode /usr/local/bin/jcode \
&& rm -rf /root/.jcode /root/.local/bin/jcode \
&& jcode --version
# `agent` user (gid 0) with an arbitrary-uid-writable HOME. The uid is
# auto-assigned (node:22-slim already occupies uid 1000 with its `node` user); at
# runtime Codeman overrides with `--user <hostUid>:0` on Linux, so the baked uid
+27 -17
View File
@@ -1637,6 +1637,23 @@ Read and write the CLI registry (`docs/cli-registry.md`). Every **write** route
| `PUT` | `/api/clis/custom/:id` | `{ label, shortBadge, binaries, argv, enabled? }` | Replace an existing custom entry. An absent `enabled` keeps the entry's current state. `400` for a stock id, `404` for an unknown one. |
| `DELETE` | `/api/clis/:id` | none | Delete a custom entry. `400` for a stock id, `404` for an unknown one. |
### Per-CLI availability probes
Each agent CLI has a read-only probe, `GET /api/<mode>/status`, which answers
`{ available, path }` for the binary the server would launch (no session needed, not gated).
`pi`, `grok`, `omp`, `copilot` and `jcode` add `version`, since their binary names are short or shared
with unrelated programs and the resolver rejects a candidate whose `--version` has the wrong
shape; `deepseek` answers a wider shape (`runnable`, `profiles`, ...). `GET /api/jcode/status`
is answered by the generic registry resolver, the same one a jcode spawn uses, so it applies
the entry's `jcode v<semver>` version anchor.
A jcode session takes `jcodeConfig: { model?, resumeSessionId? }` on `POST /api/sessions`
and `POST /api/quick-start` (`--model`, `--resume`; the resume value is a jcode session id or
short name and must start with a letter or digit). jcode takes no `envOverrides` prefix, and
`GET /api/sessions/:id/last-response` answers an empty `text` (and `messages: []` with
`?context=full`) for it: read its screen with `GET /api/sessions/:id/terminal?tail=` instead.
See `docs/jcode-integration.md`.
## MCP server sync
Copies MCP servers between the agent CLIs' own user-level config files (`docs/cli-registry.md`, "MCP server sync"). **Opt-in:** both routes answer `403 FORBIDDEN` while the synced `mcpSyncEnabled` setting is off (the default), and for a non-admin in multi-user mode, because the routes write files in the server user's home. A second `POST` while one is running answers `409 CONFLICT`.
@@ -1716,7 +1733,7 @@ Only replies from `allowedUserIds`, in `guildId`, inside a thread the bridge cre
## Local model
`GET /api/local-model` looks at the machine the server runs on and says which local coding model fits it, with which agent CLI, where an install of one stands and which models are on the machine. Seven verbs install a model (a first one, or another one beside it), switch to a model that is already there, pause and continue a job, stop and start the model runtime, delete one model, and remove everything again. It covers Apple silicon macOS only; anywhere else the GET is still a `200`, with verdict `none`, and nothing can be installed. How it works: [local-models.md](local-models.md).
`GET /api/local-model` looks at the machine the server runs on and says which local coding model fits it, with which agent CLI, and where an install of one stands. Five verbs install a model, pause and continue the install, stop and start the model runtime, and remove everything again. It covers Apple silicon macOS only; anywhere else the GET is still a `200`, with verdict `none`, and nothing can be installed. How it works: [local-models.md](local-models.md).
**Experimental.** These routes and their payloads are labeled experimental: the fields below, the states, the error codes and the sentences may change or go in a MINOR or PATCH release (see [versioning-policy.md](versioning-policy.md), "What SemVer does NOT cover", item 3). Do not build on their shape yet.
@@ -1725,13 +1742,11 @@ Every route is admin only in multi-user mode (`403 FORBIDDEN`, answered before a
| Method | Path | Body | Notes |
| -------- | -------------------------- | ------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `GET` | `/api/local-model` | none | `200` with `{ hardware, recommendation, catalogUpdated, install }`. `500 INTERNAL_ERROR` only when the recommendation itself fails, never for a failed probe. |
| `POST` | `/api/local-model/install` | `{ modelId, cli, context, acknowledgedBytes }` | Starts the one install job and answers at once; the job runs on in the server. With a model installed it adds another one: the earlier model stays on disk, and the new one is the model in use when the job ends. `400 INVALID_INPUT` when the three choices are not a row the server offers right now. `409`: `job-running`, `already-installed` (this model at this context is the one in use), `size-changed`, `sessions-open`. |
| `POST` | `/api/local-model/use` | `{ modelId }` | Switches to a model of `install.library` whose `actions` list `use`. A job of the two steps `verify` and `connect`; nothing is downloaded. `400 INVALID_INPUT` when the model is not a checked model of the library, or no coding agent for it is installed. `409`: `job-running`, `wrong-state` (nothing is installed), `already-installed`, `sessions-open`. |
| `POST` | `/api/local-model/install` | `{ modelId, cli, context, acknowledgedBytes }` | Starts the one install job and answers at once; the job runs on in the server. `400 INVALID_INPUT` when the three choices are not a row the server offers right now. `409`: `job-running`, `already-installed`, `size-changed`. |
| `POST` | `/api/local-model/cancel` | none | Interrupts the step in flight; the job is `paused` at that step. `409 wrong-state` when no job is running. |
| `POST` | `/api/local-model/resume` | `{ newPort? }` (may be absent) | Continues a `paused` or `failed` job at the same step. `newPort: true` chooses another port first. `409`: `job-running`, `wrong-state`, `sessions-open` (`newPort` while live sessions use the model). |
| `POST` | `/api/local-model/runtime` | `{ action: "start" \| "stop", newPort? }` | `stop` stops the runtime and keeps it from starting at the next boot; `start` undoes that. `newPort: true` with `start` chooses another port and moves the saved endpoint to it. `409`: `job-running`, `wrong-state` (nothing is installed), `sessions-open` (`stop` or `newPort` while live sessions use the model: their agents keep calling the address they were started with). |
| `DELETE` | `/api/local-model` | `{ removeModel? }` (may be absent) | Stops the runtime, removes the saved endpoint, restores the settings flag when this feature switched it on and no other endpoint is saved, deletes the runtime; the downloaded models (all of them) only with `removeModel: true`. With nothing installed it is what deletes the models an earlier Remove kept. `409`: `job-running`, `sessions-open`. |
| `DELETE` | `/api/local-model/models` | `{ modelId }` | Deletes ONE model from the store, through the runtime. `modelId` is a model name (`name:tag`) of `install.library` whose `actions` list `delete`, never a path. `400 INVALID_INPUT` for a name that is not on this machine. `409`: `job-running`, `wrong-state` (nothing is installed), `model-active` (the model of the installation or of the current job), `runtime-not-running`. `500 OPERATION_FAILED` when the runtime did not delete it. |
| `DELETE` | `/api/local-model` | `{ removeModel? }` (may be absent) | Stops the runtime, removes the saved endpoint, restores the settings flag when this feature switched it on and no other endpoint is saved, deletes the runtime; the model only with `removeModel: true`. `409`: `job-running`, `sessions-open`. |
Every verb answers `{ success: true, data: <install> }` with the `install` object (below) as it is after the verb. A refusal the feature names is a `409` whose envelope also carries the conflict:
@@ -1749,9 +1764,9 @@ Every verb answers `{ success: true, data: <install> }` with the `install` objec
}
```
`data.reason` is `job-running`, `already-installed` (the model is the one in use already), `size-changed` (with `expectedBytes` and `expectedGb`), `sessions-open` (with `sessions`, the names of the live sessions that use the model), `wrong-state`, `model-active` (a delete of the model in use: switch first, or remove everything) or `runtime-not-running` (a delete while the runtime is down: start it first). A `409 CONFLICT` without `data` means a config file that must not be overwritten (`settings.json` or `custom-model-hosts.json` does not parse); `error` names the fix.
`data.reason` is `job-running`, `already-installed` (remove the installed model first), `size-changed` (with `expectedBytes` and `expectedGb`), `sessions-open` (with `sessions`, the names of the live sessions that use the model) or `wrong-state`. A `409 CONFLICT` without `data` means a config file that must not be overwritten (`settings.json` or `custom-model-hosts.json` does not parse); `error` names the fix.
`POST /api/local-model/install` also answers `400` when a saved model endpoint already uses the id `local-ollama` and was not created by this feature: rename or delete that endpoint first. The check is made before anything is downloaded. Every `400` the feature names, the `507 OPERATION_FAILED` of a first install on a full disk and the `500 OPERATION_FAILED` of a delete the runtime refused carry their sentence as `data.message` (`{ text, vars }`, like a `409`), so a client can print it in its own language.
`POST /api/local-model/install` also answers `400` when a saved model endpoint already uses the id `local-ollama` and was not created by this feature: rename or delete that endpoint first. The check is made before anything is downloaded. Every `400` the install names, and the `507 OPERATION_FAILED` of a first install on a full disk, carries its sentence as `data.message` (`{ text, vars }`, like a `409`), so a client can print it in its own language.
**Consent is bound to the size shown.** `acknowledgedBytes` must be the `installBytes` of the chosen row: the model download plus the runtime download while the runtime is not unpacked yet. The server compares it with its own total before anything starts and answers `409 size-changed` when they differ, so a click confirms exactly the size the person saw.
@@ -1777,13 +1792,12 @@ A probe that fails is an answer, not an error: a server that is not on macOS, or
- `tag?`: `{ kind, label }`, the one tag of the row. `kind` is `tested`, `best`, `tight`, `slow` or `small`;
- `fit`: `good` (up to 0.85 of the budget) or `tight` (up to 0.98), with its label `fitLabel`;
- `speed`: `ok`, `slow` (under 10 tokens per second) or `unknown`, with `speedLabel` (absent for `ok`) and `tokensPerSecond` (rounded to one decimal, absent for `unknown`; `speed` is decided on the unrounded figure, so `10` can be `slow`). `unknown` means the chip is in no row of the catalogue's chip table, or the catalogue has no speed figure for the model: a speed is never guessed;
- `tested`: we ran this model with this CLI on a Mac like this one (same chip, same memory size). Its `tokensPerSecond` is then the speed measured in that run;
- `library?`: the model at this context is a checked model on this machine already (see `install.library`). `active`: it is the model in use. `ready`: `POST /api/local-model/use` switches to it, and nothing is downloaded. Absent otherwise.
- `tested`: we ran this model with this CLI on a Mac like this one (same chip, same memory size). Its `tokensPerSecond` is then the speed measured in that run.
- `budgetBytes` and `budgetGb`: the memory a model may use on this Mac (`0` for `not-macos` and `intel`, where none is computed). `budgetSource` is `estimated` (computed from the installed memory: all there is before the runtime ran), `wired-limit` (taken from `iogpu.wired_limit_mb`) or `measured` (the device total the runtime itself printed when it started; the GET uses it from the first install on).
- `catalogUpdated`: ISO date on which the catalogue's rows were last checked.
- `install`: where the install stands and what can be done about it.
- `state`: `idle` (nothing installed, no job), `installing`, `paused` (Cancel or a server restart stopped the job; it never continues without a click), `failed` or `installed`.
- `step` and `steps[]` (present with `installing`, `paused` and `failed`): the step the job is in or stopped in, and the steps of this job in order, out of `runtime`, `model`, `verify`, `agent`, `connect`. `agent` is absent from a job whose agent CLI was already installed. A switch (`POST .../use`) is the two steps `verify` and `connect`.
- `step` and `steps[]` (present with `installing`, `paused` and `failed`): the step the job is in or stopped in, and the steps of this job in order, out of `runtime`, `model`, `verify`, `agent`, `connect`. `agent` is absent from a job whose agent CLI was already installed.
- `progress` (while a download runs, and as it stood on a paused or failed job): `completedBytes`, `totalBytes`, `completedGb`, `totalGb`, and while it moves `bytesPerSecond`, `mbPerSecond` (whole decimal megabytes per second as text) and `secondsLeft`. During the `model` step both byte figures come from the download's own lines, not from the catalogue.
- `error` (with `failed`; with `installed` only when something blocks a session): `{ code, message: { text, vars? }, next, nextModel?, detail? }`. `next` is the ONE action that helps: `retry` (`POST .../resume`), `other-port` (`POST .../resume` or `.../runtime` with `newPort: true`), `next-model` (choose again; `nextModel` is `{ modelId, modelLabel }` when the server knows one), `start` (`POST .../runtime`) or `remove` (`DELETE`). `detail` is raw text for the curious (an installer's last lines), not translated.
- `model` (`{ id, label }`), `cli`, `cliLabel`, `context`: the choice of the job or of the installation. `port`: the runtime's loopback port, once chosen.
@@ -1793,9 +1807,7 @@ A probe that fails is an answer, not an error: a server that is not on macOS, or
- `developerTools`: `present`, `missing` (the Mac has no Xcode or Command Line Tools, so macOS will offer to install them the first time an agent runs `git` or `python3`) or `unknown`.
- `runtime`: `{ version, installed, downloadBytes, downloadGb, unpackedGb }`. `downloadBytes` is `0` once the runtime is unpacked.
- `folder`: the absolute folder everything of this feature is kept in.
- `library[]`: the models on this machine, the model in use first. Empty with `idle` (no runtime is there to ask). Each row: `id` (the name the runtime lists, `name:tag`), `label`, `context?` (the context it was checked with), `sizeBytes?` and `sizeGb?` (its size on disk as the runtime reports it, else as recorded when it was checked), `active` (it is the model in use), `verified` (Codeman checked it), `status` and `actions[]`. `status` is `ready`, `changed` (checked, and the runtime now reports another digest under the name), `missing` (checked, and the runtime no longer lists it) or `unverified` (the runtime lists it and Codeman never checked it: a model an earlier Remove kept, the model of a job that has not passed its check, or one another program pulled through the runtime's port). `actions` is what the server accepts for THAT row right now, out of `use` and `delete`; `delete` needs the running runtime, and a name that is not a plain `name:tag` is listed with no action.
- `store?`: `{ bytes, gb }`, what the model store takes on disk (the files of its blob folder, so shared layers count once and an interrupted download is included). Present in every state once it was measured; cached for about thirty seconds.
- `actions[]`: exactly what the server accepts right now, out of `install` (with `installed`: add another model), `cancel`, `resume`, `start`, `stop`, `remove` (with `idle` only while `store` holds something), `use` and `delete-model` (only while a row of `library` offers them) and `start-session` (not a route here: start a session on `endpointId`; offered only while the runtime runs and nothing blocks a session).
- `actions[]`: exactly what the server accepts right now, out of `install`, `cancel`, `resume`, `start`, `stop`, `remove` and `start-session` (not a route here: start a session on `endpointId`; offered only while the runtime runs and nothing blocks a session).
Error codes (`install.error.code`), each with one next step:
@@ -1805,11 +1817,9 @@ Error codes (`install.error.code`), each with one next step:
| `start` | `runtime-stopped` (the runtime crashed three times in a row and is no longer restarted) |
| `other-port` | `port-in-use` |
| `next-model` | `model-not-found`, `does-not-fit`, `no-tool-call` |
| `remove` | `model-changed` (the model's digest is no longer the one stored when it was checked; a session on it is refused, and so is a switch to it) |
| `remove` | `model-changed` (the model's digest is no longer the one stored at install time; a session on it is refused) |
Two codes are only ever the reason of a refused session start, never on `install.error`: `runtime-not-running` (the person stopped the runtime) and `job-unfinished` (a job that changes the model in use is running or stopped: nothing is started until it ended `installed` again).
Starting a session on the saved endpoint goes through the existing custom model launch (`POST /api/quick-start` with `customModel`, or `POST /api/sessions/:id/custom-model`). For the endpoint this feature manages both answer `OPERATION_FAILED` with one sentence while the runtime is not running, while a job that changes the model in use is running or stopped, when the model's digest changed, or for a model other than the one in use.
Starting a session on the saved endpoint goes through the existing custom model launch (`POST /api/quick-start` with `customModel`, or `POST /api/sessions/:id/custom-model`). For the endpoint this feature manages both answer `OPERATION_FAILED` with one sentence while the runtime is not running, when the model's digest changed, or for a model other than the installed one.
Every computed size a person reads travels twice: as bytes, and as decimal GB text without the unit (`"needGb": "29.6"`). Show the text and add the unit; do not divide the bytes yourself, so one request never shows two different roundings. The installed memory is the exception named above: show `memClassGb`.
+7 -13
View File
@@ -37,11 +37,11 @@ Pure helpers unit-tested in `test/base-path.test.ts` + `test/webview-proxy.test.
## Session launch modes
**Terminal colour env** — the stock registry decides each CLI's colour vars. Claude, Codex, Gemini, Antigravity, Pi, Grok, DeepSeek and OMP export `COLORTERM=truecolor`; `shell` and `opencode` unset it. All of those except Claude also unset `NO_COLOR`, so a user who exports `NO_COLOR` globally keeps monochrome Claude panes. The variable matters because a CLI inheriting no `COLORTERM` quantizes every RGB color it draws down to whatever palette `TERM` alone implies, and the pane's `TERM` is not a constant. ⚠️ Codeman sets no `default-terminal` and passes tmux no `-f`, so it is tmux's default (`tmux-256color` since 3.2) unless the user's own `~/.tmux.conf` says otherwise, which Codeman's server DOES read. At `tmux-256color` supports-color reports 256 colors and `rgb(55, 55, 55)` lands on `ESC[48;5;237m`: visible, but not the color the theme named. Where `TERM` resolves to a 16-color entry instead (tmux older than 3.2, or a conf setting `default-terminal screen`) every dark background collapses to `ESC[40m`, the terminal's own black, and the block disappears entirely. That is why the same Claude theme looks different on two machines, and why a bug report here is worth pairing with the reporter's `tmux -V` and their pane's real `TERM`. ⚠️ These declarations reach the local tmux pane via `buildEnvExports()`, its attach client via `cliExportsTruecolor()`, and the direct-PTY fallback via `buildClaudeEnv()` — they do NOT reach a remote pane, which `buildRemoteLaunchCommand()` builds with no env exports at all. A Docker pane takes `COLORTERM=truecolor` from the hardcoded `envCreate`/`execEnv` in `tmux-manager.ts`, which apply to every mode including the two the registry says must unset it. A `~/.codeman/clis.json` override replaces these arrays wholesale (`deepMerge`), so a custom entry can drop either list. That is why both consumers apply the entry BEFORE Codeman's own variables rather than after: `buildEnvExports()` emits `...cliEnv` ahead of `export CODEMAN_MUX=1`, and `buildClaudeEnv()` assigns `PATH`/`TERM`/`CODEMAN_*` after its unset/export loop. Reversed, a config-supplied `unset` naming `CODEMAN_HOOK_SECRET_FILE` would strip it on one path and not the other.
**Terminal colour env** — the stock registry decides each CLI's colour vars. Claude, Codex, Gemini, Antigravity, Pi, Grok, DeepSeek, OMP and jcode export `COLORTERM=truecolor`; `shell` and `opencode` unset it. All of those except Claude also unset `NO_COLOR`, so a user who exports `NO_COLOR` globally keeps monochrome Claude panes. The variable matters because a CLI inheriting no `COLORTERM` quantizes every RGB color it draws down to whatever palette `TERM` alone implies, and the pane's `TERM` is not a constant. ⚠️ Codeman sets no `default-terminal` and passes tmux no `-f`, so it is tmux's default (`tmux-256color` since 3.2) unless the user's own `~/.tmux.conf` says otherwise, which Codeman's server DOES read. At `tmux-256color` supports-color reports 256 colors and `rgb(55, 55, 55)` lands on `ESC[48;5;237m`: visible, but not the color the theme named. Where `TERM` resolves to a 16-color entry instead (tmux older than 3.2, or a conf setting `default-terminal screen`) every dark background collapses to `ESC[40m`, the terminal's own black, and the block disappears entirely. That is why the same Claude theme looks different on two machines, and why a bug report here is worth pairing with the reporter's `tmux -V` and their pane's real `TERM`. ⚠️ These declarations reach the local tmux pane via `buildEnvExports()`, its attach client via `cliExportsTruecolor()`, and the direct-PTY fallback via `buildClaudeEnv()` — they do NOT reach a remote pane, which `buildRemoteLaunchCommand()` builds with no env exports at all. A Docker pane takes `COLORTERM=truecolor` from the hardcoded `envCreate`/`execEnv` in `tmux-manager.ts`, which apply to every mode including the two the registry says must unset it. A `~/.codeman/clis.json` override replaces these arrays wholesale (`deepMerge`), so a custom entry can drop either list. That is why both consumers apply the entry BEFORE Codeman's own variables rather than after: `buildEnvExports()` emits `...cliEnv` ahead of `export CODEMAN_MUX=1`, and `buildClaudeEnv()` assigns `PATH`/`TERM`/`CODEMAN_*` after its unset/export loop. Reversed, a config-supplied `unset` naming `CODEMAN_HOOK_SECRET_FILE` would strip it on one path and not the other.
### External CLI modes (OpenCode, Codex, Gemini, Antigravity, Pi, Grok, DeepSeek, OMP)
**External CLI modes (OpenCode, Codex, Gemini, Antigravity, Pi, Grok, DeepSeek, OMP)**: `isExternalCliMode()` in `session.ts` (`mode === 'opencode' || 'codex' || 'gemini' || 'antigravity' || 'pi' || 'grok' || 'deepseek'`) gates Claude-specific behavior — Ralph tracker, BashToolParser, token/CLI-info parsing, and ❯-prompt readiness detection are all skipped (these CLIs render their own TUIs; readiness = output stabilization instead). ⚠️ **Work detection left this gate in #385** and is now per-CLI `capabilities.workDetect` data (`promptGlyph` + `workingLine`), because gating it on the mode left every Codex session reporting `idle` for its entire life; a CLI declaring neither falls back to Claude's pair, which is logic-identical to the pre-registry behaviour. All seven modes **require tmux — no direct PTY fallback** — because secrets are injected via `tmux setenv` (socket-scoped `${this.tmux()} setenv`, never on the spawn command line): OpenCode gets `OPENCODE_CONFIG_CONTENT` etc., Codex gets `OPENAI_API_KEY`/`CODEX_API_KEY`/`CODEX_HOME` (`setCodexEnvVars`), Gemini gets `GEMINI_API_KEY`/`GOOGLE_API_KEY`/`GOOGLE_CLOUD_PROJECT`/`GOOGLE_APPLICATION_CREDENTIALS`/`GOOGLE_GENAI_USE_VERTEXAI` etc. (`setGeminiEnvVars`, all in `tmux-manager.ts`). Codex specifics: command built by `buildCodexCommand()` (`--no-daemon` behind the `noDaemonFlag` gate at 0.162.0, measured against codex's own version, local launches only; `--model`, `--config model_reasoning_effort=<level>` from `reasoningEffort`, `--config sandbox_workspace_write.network_access=true` from `networkAccess` (the **Allow network access** app setting `codexNetworkAccess`, a launch default and a privileged param; ⚠️ with it on, a command in codex's sandbox can call Codeman's API and start a session outside that sandbox, password or not), `--config tui.animations=<bool>` from `animations`, `resume <id>`, `--dangerously-bypass-approvals-and-sandbox` from the `codexConfig` payload / `codexDangerouslyBypassApprovals` app setting; `renderMode` is schema-coerced to `'hybrid'`, the only supported mode). Gemini specifics: command built by `buildGeminiCommand()` (`--skip-trust` always, `--approval-mode <default|auto_edit|yolo|plan>` defaulting to `yolo` for parity with Claude's `--dangerously-skip-permissions`, `--model`, `--resume` from the `geminiConfig` payload); availability via `GET /api/gemini/status` — session/quick-start routes fail with `OPERATION_FAILED` + install hint (`npm install -g @google/gemini-cli`) when missing. Codex, Gemini, Antigravity, Pi, Grok, DeepSeek and OMP export `COLORTERM=truecolor` and unset `NO_COLOR`; `opencode` unsets `COLORTERM`. **Terminal colour env** under Session launch modes covers Claude and says which panes those declarations actually reach. Gemini joins `isAltScreenStripMode()` (Codex/Claude/Gemini are Ink TUIs that repaint inline → strip alt-screen/`3J` so scrollback survives). Codex availability via `GET /api/codex/status`. Antigravity specifics: command built by `buildAntigravityCommand()` (`--model`, `--conversation <id>` resume, `--dangerously-skip-permissions` from the `antigravityConfig` payload); availability via `GET /api/antigravity/status` — routes fail with `OPERATION_FAILED` + install hint (`curl -fsSL https://antigravity.google/cli/install.sh | bash`) when missing. Unlike the other three it is NOT an npm package (standalone binary, `~/.local/bin/agy`), which is why `docker/agent.Dockerfile` installs it with its own `--dir /usr/local/bin` step rather than in the `npm install -g` line, and why it does NOT join `isAltScreenStripMode()`. Frontend: run-mode dropdown → `runCodex()`/`runGemini()` in `session-ui.js` ("Run CX"/"Run GM" labels), App Settings → Agents & CLIs → Codex; Respawn/Ralph options are Claude-only, so session options open on the Session tab for external CLI sessions. ⚠️ `run*()` MUST unwrap the `{success,data}` envelope (`(await res.json()).data.available` / `data.data.sessionId`) — reading the raw shape silently breaks the run. Tests: `test/run-mode-ui.test.ts` + `test/gemini-mode.test.ts` (vm-sandbox harness, no real DOM). Grok specifics: command built by `buildGrokCommand()` (`--always-approve` from `grokConfig.alwaysApprove` — grok's `bypassPermissions` permission mode, deny rules still apply; `--model`; `--resume <id>` / `--continue`, id-regexed so grok's resume-by-TITLE feature can never put an arbitrary string on the spawn line); availability via `GET /api/grok/status`, which carries `version` because the resolver version-probes candidates (`grok` has npm squatters, e.g. @vibe-kit/grok-cli — `GROK_VERSION_REGEX` is shared with the dependency registry so doctor and run mode agree). Like antigravity it is a standalone binary (xAI installer → `~/.grok/bin`, symlinked into `~/.local/bin`), so `docker/agent.Dockerfile` installs it in its own step (copy to `/usr/local/bin`, drop root's `~/.grok` in the same layer) and it stays OUT of `isAltScreenStripMode()` (fuLine truncated
**External CLI modes (OpenCode, Codex, Gemini, Antigravity, Pi, Grok, DeepSeek, OMP, Copilot, jcode)**: `isExternalCliMode()` in `session.ts` (`mode === 'opencode' || 'codex' || 'gemini' || 'antigravity' || 'pi' || 'grok' || 'deepseek'`) gates Claude-specific behavior — Ralph tracker, BashToolParser, token/CLI-info parsing, and ❯-prompt readiness detection are all skipped (these CLIs render their own TUIs; readiness = output stabilization instead). ⚠️ **Work detection left this gate in #385** and is now per-CLI `capabilities.workDetect` data (`promptGlyph` + `workingLine`), because gating it on the mode left every Codex session reporting `idle` for its entire life; a CLI declaring neither falls back to Claude's pair, which is logic-identical to the pre-registry behaviour. All seven modes **require tmux — no direct PTY fallback** — because secrets are injected via `tmux setenv` (socket-scoped `${this.tmux()} setenv`, never on the spawn command line): OpenCode gets `OPENCODE_CONFIG_CONTENT` etc., Codex gets `OPENAI_API_KEY`/`CODEX_API_KEY`/`CODEX_HOME` (`setCodexEnvVars`), Gemini gets `GEMINI_API_KEY`/`GOOGLE_API_KEY`/`GOOGLE_CLOUD_PROJECT`/`GOOGLE_APPLICATION_CREDENTIALS`/`GOOGLE_GENAI_USE_VERTEXAI` etc. (`setGeminiEnvVars`, all in `tmux-manager.ts`). Codex specifics: command built by `buildCodexCommand()` (`--no-daemon` behind the `noDaemonFlag` gate at 0.162.0, measured against codex's own version, local launches only; `--model`, `--config model_reasoning_effort=<level>` from `reasoningEffort`, `--config sandbox_workspace_write.network_access=true` from `networkAccess` (the **Allow network access** app setting `codexNetworkAccess`, a launch default and a privileged param; ⚠️ with it on, a command in codex's sandbox can call Codeman's API and start a session outside that sandbox, password or not), `--config tui.animations=<bool>` from `animations`, `resume <id>`, `--dangerously-bypass-approvals-and-sandbox` from the `codexConfig` payload / `codexDangerouslyBypassApprovals` app setting; `renderMode` is schema-coerced to `'hybrid'`, the only supported mode). Gemini specifics: command built by `buildGeminiCommand()` (`--skip-trust` always, `--approval-mode <default|auto_edit|yolo|plan>` defaulting to `yolo` for parity with Claude's `--dangerously-skip-permissions`, `--model`, `--resume` from the `geminiConfig` payload); availability via `GET /api/gemini/status` — session/quick-start routes fail with `OPERATION_FAILED` + install hint (`npm install -g @google/gemini-cli`) when missing. Codex, Gemini, Antigravity, Pi, Grok, DeepSeek and OMP export `COLORTERM=truecolor` and unset `NO_COLOR`; `opencode` unsets `COLORTERM`. **Terminal colour env** under Session launch modes covers Claude and says which panes those declarations actually reach. Gemini joins `isAltScreenStripMode()` (Codex/Claude/Gemini are Ink TUIs that repaint inline → strip alt-screen/`3J` so scrollback survives). Codex availability via `GET /api/codex/status`. Antigravity specifics: command built by `buildAntigravityCommand()` (`--model`, `--conversation <id>` resume, `--dangerously-skip-permissions` from the `antigravityConfig` payload); availability via `GET /api/antigravity/status` — routes fail with `OPERATION_FAILED` + install hint (`curl -fsSL https://antigravity.google/cli/install.sh | bash`) when missing. Unlike the other three it is NOT an npm package (standalone binary, `~/.local/bin/agy`), which is why `docker/agent.Dockerfile` installs it with its own `--dir /usr/local/bin` step rather than in the `npm install -g` line, and why it does NOT join `isAltScreenStripMode()`. Frontend: run-mode dropdown → `runCodex()`/`runGemini()` in `session-ui.js` ("Run CX"/"Run GM" labels), App Settings → Agents & CLIs → Codex; Respawn/Ralph options are Claude-only, so session options open on the Session tab for external CLI sessions. ⚠️ `run*()` MUST unwrap the `{success,data}` envelope (`(await res.json()).data.available` / `data.data.sessionId`) — reading the raw shape silently breaks the run. Tests: `test/run-mode-ui.test.ts` + `test/gemini-mode.test.ts` (vm-sandbox harness, no real DOM). Grok specifics: command built by `buildGrokCommand()` (`--always-approve` from `grokConfig.alwaysApprove` — grok's `bypassPermissions` permission mode, deny rules still apply; `--model`; `--resume <id>` / `--continue`, id-regexed so grok's resume-by-TITLE feature can never put an arbitrary string on the spawn line); availability via `GET /api/grok/status`, which carries `version` because the resolver version-probes candidates (`grok` has npm squatters, e.g. @vibe-kit/grok-cli — `GROK_VERSION_REGEX` is shared with the dependency registry so doctor and run mode agree). Like antigravity it is a standalone binary (xAI installer → `~/.grok/bin`, symlinked into `~/.local/bin`), so `docker/agent.Dockerfile` installs it in its own step (copy to `/usr/local/bin`, drop root's `~/.grok` in the same layer) and it stays OUT of `isAltScreenLine truncated
**DeepSeek Harness (`dsh`) specifics** — the mode that breaks three of the assumptions the six above share, so read this before changing anything about it.
@@ -65,6 +65,8 @@ Model is NOT a session field: it is a composition entry in the profile's config
**OMP (`omp`, Oh My Pi) specifics** (`docs/omp-integration.md`): architecturally the simplest of the family — omp owns its own auth, provider routing, and trust decisions entirely in `~/.omp` config files (default `tools.approvalMode: yolo`), so `buildOmpCommand()` only ever emits `--model`/`--resume <id>`/`--continue`, and there is no bypass-permissions flag for Codeman to wire or clamp. ⚠️ **That does NOT make the multi-user clamp a no-op**: `OMP_*` is an allowlisted `envOverrides` prefix and admits `OMP_AUTH_BROKER_URL`/`OMP_AUTH_BROKER_TOKEN` (where omp resolves credentials from), both dropped for a non-granted owner in `clampEnvOverridesForOwner()` — the same shape as `DEEPSEEK_BASE_URL` — even though, unlike DeepSeek, Codeman forwards no operator-held key into an omp pane today (found in Ark0N/Codeman#353 review). `--continue` alone is ambiguous the moment any other omp conversation has touched the same working directory more recently, since it just picks the newest session file on disk — `resolveAndClaimOmpSessionId()` (`src/utils/omp-session-resolver.ts`) resolves and PINS the real id instead, verifying each candidate's own file header (`{"type":"session","id",cwd"}`, not just the mangled-directory match) and tracking already-claimed ids in a process-wide registry so two omp tabs in the same case dir can't alias onto each other's conversation. ⚠️ Resolution/pinning happens ONLY at the point a respawn is actually confirmed (`_pinOmpRespawnId()`, called from `_setupOrAttachMuxSession()`'s dead-pane branch and `reattachRemote()`) — earlier code resolved eagerly while merely building respawn options, which could mis-pin a still-ALIVE session's id purely from boot-recovery timing. `src/omp-transcript.ts` independently scans `~/.omp/agent/sessions/**/*.jsonl` for Past Sessions history, the omp analog of Claude's own transcript scan, so a conversation survives even a full "Kill Tmux". ⚠️ omp's own env knobs are mostly `PI_*`, not `OMP_*` (`PI_CONFIG_DIR`, `PI_CODING_AGENT_DIR`, `PI_CODING_AGENT_SESSION_DIR`, `PI_SUBPROCESS_CMD`, `PI_SHELL_PREFIX`), and `PI_*` is already allowlisted globally for pi — so a redirected `PI_CONFIG_DIR` silently moves the `~/.omp` tree the resolver and transcript scanner hardcode, degrading pinning/history with no error; a known gap shared with pi, not fixed here. ⚠️ Docker: `appendResumeFlag()`'s `case 'omp'` keys off the top-level `resumeSessionId`, which Docker panes never receive for omp (built from `defaultDockerCommandForMode`, with no `ompConfig` threaded through) — host-side history recovery and pinning work through the shared `sessions/` mount, but `--resume` does not currently reach an in-container omp process on respawn (flagged in review, not yet fixed). Stays out of `isAltScreenStripMode()` (narrow scrollback strip, alt-screen toggles only) and lands on the `'buffer'` local-echo policy via the `_updateLocalEchoState` fallthrough, same as grok and pi. Resolver: `omp-cli-resolver.ts` version-probes like pi/grok (`omp` is a short, generic name) and requires `omp/<semver>`-shaped output; `OMP_SEARCH_DIRS` leads with `~/.local/bin` (omp.sh's installer targets `$HOME/.local/bin` with no `--dir` override — verified against a real `--no-cache` Docker build, `~/.omp/bin` was the wrong first guess). Tests: `test/omp-mode.test.ts`, `test/omp-cli-resolver.test.ts`, `test/omp-session-resolver.test.ts`, `test/omp-fresh-run-no-resume.test.ts`.
**jcode specifics** (`docs/jcode-integration.md`): every jcode TUI is a CLIENT of one shared per-user daemon (`jcode serve`, `setsid`, but still the first client's child by parent pid, so ⚠️ `capabilities.sharedDaemon` makes `killSession` spare descendants outside the pane's process group via `descendantsInPaneGroup()` in `proc-tree.ts`; without it, closing the tab that started the daemon cut every other jcode session off, measured), and the agent's tools run in the DAEMON with the daemon's environment (measured: a second client's env var read back empty from its bash tool). So ⚠️ the agent skill is NOT injected (`agentSkillInjection: false`: the tools would carry the `CODEMAN_*` identity of whichever session started the daemon), and ⚠️ `JCODE_*` is NOT an `envOverrides` prefix (`allowedPrefixes: []`: that namespace is daemon-read and includes `JCODE_HOOK_*`, `JCODE_SPAWN_HOOK`, `JCODE_*_COMMAND` and `JCODE_*_API_BASE`, so a per-session value does nothing or reaches every jcode session). No approval prompts and no bypass flag (pi-shaped: `privilegedParams: []`, no `blockedLine`). ⚠️ `last-response` answers an honest empty for it: the generic rule `capabilities.external && transcript === 'none'` placed AFTER the pane-segmenter branch (it matches jcode alone today) keeps it off the segmenter, which served jcode's welcome banner as the answer, and off the Claude transcript scan, which could adopt a Claude conversation from the same folder. Binary resolution is the generic registry resolver (`/api/jcode/status` included); there is no per-CLI resolver module.
**Codex input path (issues #218/#219/#220/#222)**: codex-mode sessions use **predictive write-through echo, never the buffer overlay**. The buffer overlay stays disabled exactly as 1.12.2 left it (`_updateLocalEchoState` in terminal-ui.js, same branch as shell; `_localEchoEnabled` remains false for codex), and the additive `_localEchoPolicy` field selects `'predict'` for codex when `localEchoEnabled` is on. Codex's composer is interactive per keystroke: typing "/" pops a live-filtering command picker (#222 was "picker never appears" because the "/" sat in the overlay until Enter), the composer grows/rewraps as it fills (#220: a long typed prompt existed ONLY in the overlay DOM, so codex never grew the composer), arrows and Ctrl+Backspace edit server-side state (#218: arrows were forwarded to an EMPTY composer while the typed text sat pending; the `\x08` control-char flush then left the overlay stateless so `\x7f` was swallowed as "nothing to remove"), and pastes arrive bracketed (#219: `terminal.paste()` wraps in `\x1b[200~..201~`, which the multi-byte-ESC branch forwarded WITHOUT flushing pending text, so the paste landed before it). The shared overlay branch (claude/gemini/opencode still buffer) gained three fixes: bracketed pastes flush pending text first, composer nav keys (`isComposerNavKey` allowlist in `CodemanTerminalInput` — arrows/Home/End/Delete/PgUp/PgDn incl. modifiers, deliberately excluding DA/CPR/DSR query responses) flush and hand the session to **pass-through** (plain PTY echo until Enter/Ctrl+C, because after cursor movement the append-only overlay cannot track edits), and a backspace that finds no overlay state is FORWARDED instead of swallowed. ⚠️ **Codex drops keystrokes that arrive in the same PTY read as a bracketed paste** (upstream `bottom_pane/paste_burst.rs` holds rapid chars for paste classification; verified against codex 0.147.0 by writing `hello\x1b[200~PASTED\x1b[201~` into the tmux client PTY in one write → composer shows only `PASTED`, while a 100ms gap yields `helloPASTED`), so the flush sends the typed text immediately and delays the paste sequence by 80ms — the same two-phase shape as the Enter branch's delayed `\r`. Related protocol fact: xterm.js sends `0x08` for Ctrl+Backspace, which codex's keymap binds to delete-ONE-char (`ctrl(Char('h'))`); real word-delete needs the kitty CSI-u encoding (`\x1b[127;5u`), which xterm.js 6.0.0 cannot emit (kitty support lands in 6.1.0-beta) — an upstream limitation, not a Codeman bug. E2E technique: codex 0.147 reaches its composer with any dummy key in `$CODEX_HOME/auth.json` (`{"OPENAI_API_KEY":"sk-test-..."}`), so a real TUI can be driven headlessly (envOverrides `CODEX_HOME` rides the `CODEX_*` allowlist) without real credentials. **Predictive write-through echo invariants** (the codex echo mode, `PredictiveEchoAddon` in `packages/xterm-zerolag-input`): (1) the onData hook `_predictHookOnData` is a PLAIN STATEMENT between the buffer block and Normal Mode — no `return`, try/catch-wrapped, never touches `_pendingInput` — so the wire path is byte-identical with the predictor active, absent or throwing (pinned at vm level and by an end-to-end trace-equality E2E); (2) it ships as a SEPARATE bundle `vendor/xterm-predictive-echo.js` so the zerolag bundle stays byte-identical, and a missing/broken bundle degrades codex to plain 1.12.2 echo (`typeof PredictiveEchoOverlay !== 'undefined'` guard); (3) predictions paint only while the cursor sits on the measured composer row (`isCodexComposerRow`, `CODEX_COMPOSER_ROW_RE = /^› /` — matches the empty-composer placeholder, typing, and the slash picker; rejects modal rows and 2-space wrapped continuation rows, the #220 ghost zone, which deliberately fall back to real echo); (4) reconciliation reads the PARSED buffer with `baseY + row` (xterm's `cursorY` is baseY-relative; `viewportY` only coincides while scrolled to bottom), confirms prefix-only on cell match PLUS cursor advance, cascades only on TWO consecutive foreign NON-BLANK passes (blanks are neutral: codex clears its placeholder on first echo), and TTL-bounds the rest; (5) after an UNPREDICTED wire edit (backspace into echoed text, any 'clear'-classified input, an IME/plain-paste 'text' commit, or every bypass send incl. `_handleCjkInput`) the addon holds new predictions until the next PARSED write: the displayed cursor is stale for one RTT and anchoring on it paints ghosts one cell off; (6) the per-device `localEchoEnabled` toggle is the kill switch returning exact 1.12.2 behavior. Measured constants + fixtures: `docs/predictive-echo-plan.md`, recorded via `scripts/dev/record-codex-frames.mjs` through the production tmux+strip pipeline. Tests: `test/local-echo-codex-gating.test.ts` (vm harness: nav-key + predict classifier truth tables, policy matrix, wire-neutrality pins), `packages/xterm-zerolag-input/test/` (addon laws, real-fixture replay, seeded fuzz), `test/codex-predictive-echo.test.ts` (E2E vs real codex incLine truncated
Further detail: with the registry, a CLI that declares its own composer glyph and working line in `capabilities.workDetect` gets the same screen-probed idle confirmation claude gets (see the ❯ note in CLAUDE.md's idle-detection paragraph), and one that declares neither keeps the output-stabilization behaviour. The tmux-only rule now covers all EIGHT external modes (OMP included): none has a direct PTY fallback, because secrets are injected via socket-scoped `tmux setenv` and never on the spawn command line.
@@ -249,7 +251,7 @@ Further detail: the `<prefix>: <title>` form (`w3-myapp: fix the login redirect`
### Terminal scrollback: strip flavors and wheel/touch forwarding
**Three strip flavors, one carry** (#205, `session.ts:_handleTerminalOutput`): the FULL strip (`isAltScreenStripMode` = codex/claude/gemini) removes alt-screen toggles, `3J`, and mouse-tracking DECSETs. The MOUSE strip (`isMuxMouseStripMode` = opencode, copilot and pi) removes alt-screen toggles AND the mouse DECSETs but KEEPS `3J` — the middle case, for a mouse-capable full-screen TUI: its tracking DECSETs reach the browser (tmux `mouse off` passes the pane's modes through to the client), xterm obeys them, and then every DRAG is reported to the TUI instead of selecting text — so mark-and-copy silently did nothing (measured 62 `none` / 18 `any` over 16s, 5/5 dead drags while `any`) and the obvious fallback, Ctrl+C, is opencode's `app_exit`. Copilot is the same shape (measured on 1.0.95: alt screen, mouse reporting on): left on the narrow strip, Auto Copy and copy-on-select did nothing and only Shift+drag selected. `3J` stays because a TUI is not a `clear` consumer. Every other mode (shell/antigravity/grok/deepseek/omp) gets the NARROW strip (`isMuxAltScreenOnlyStripMode`) — alt-screen toggles ONLY — and only when tmux-backed (`useMux`). Rationale: the tmux CLIENT emits `smcup` as its first bytes at attach, before any program runs, parking xterm in the scrollback-less alternate buffer for the whole session (touch scrolling no-ops; xterm's own wheel handler converts the wheel to Up/Down arrows = readline history cycling — both #205 symptoms). tmux never forwards a pane program's alt-screen toggles to its client (it repaints instead; measured — vim/less inside a pane emit zero to the client), so the only thing the narrow strip ever removes is tmux's own smcup. It keeps `3J` (a user's `clear` is a deliberate scrollback wipe) and the mouse DECSETs (tmux passes those through even with `mouse off`; stripping them would break htop/vim mouse support — which is exactly why the mouse strip is opt-in per CLI and not part of the narrow flavour). ⚠️ The `useMux` gate is load-bearing: `startShell()`/`startInteractive()` fall back to a DIRECT PTY when mux creation fails, and there the inner program's own `?1049h` really does reach xterm — stripping it would break vim/less/htop for real. The replay path (`session-routes.ts`, via `session.usesMux`) applies the same three branches; the frontend gate (`_shouldReportMouseToCli()`) keeps no mode list at all: it reads only the published `cliMouseTracking`, which the server sets solely in a DECSET-stripping branch (full or mouse), so it can only be true for a mode whose DECSETs are stripped — the modes where the browser's hand-encoded tap (`_sendSyntheticSgrTap`) is the only way a click still reaches the CLI. Whichever modes the registry strips, the browser follows (pinned in `test/claude-scrollback-strip.test.ts`). The chunk-boundary carry (`_altScreenSeqCarry`) runs for all three flavors. Tests: `test/claude-scrollback-strip.test.ts`, `test/terminal-touch-tap.test.ts`.
**Three strip flavors, one carry** (#205, `session.ts:_handleTerminalOutput`): the FULL strip (`isAltScreenStripMode` = codex/claude/gemini) removes alt-screen toggles, `3J`, and mouse-tracking DECSETs. The MOUSE strip (`isMuxMouseStripMode` = opencode, copilot, pi and jcode) removes alt-screen toggles AND the mouse DECSETs but KEEPS `3J` — the middle case, for a mouse-capable full-screen TUI: its tracking DECSETs reach the browser (tmux `mouse off` passes the pane's modes through to the client), xterm obeys them, and then every DRAG is reported to the TUI instead of selecting text — so mark-and-copy silently did nothing (measured 62 `none` / 18 `any` over 16s, 5/5 dead drags while `any`) and the obvious fallback, Ctrl+C, is opencode's `app_exit`. Copilot is the same shape (measured on 1.0.95: alt screen, mouse reporting on): left on the narrow strip, Auto Copy and copy-on-select did nothing and only Shift+drag selected. `3J` stays because a TUI is not a `clear` consumer. Every other mode (shell/antigravity/grok/deepseek/omp) gets the NARROW strip (`isMuxAltScreenOnlyStripMode`) — alt-screen toggles ONLY — and only when tmux-backed (`useMux`). Rationale: the tmux CLIENT emits `smcup` as its first bytes at attach, before any program runs, parking xterm in the scrollback-less alternate buffer for the whole session (touch scrolling no-ops; xterm's own wheel handler converts the wheel to Up/Down arrows = readline history cycling — both #205 symptoms). tmux never forwards a pane program's alt-screen toggles to its client (it repaints instead; measured — vim/less inside a pane emit zero to the client), so the only thing the narrow strip ever removes is tmux's own smcup. It keeps `3J` (a user's `clear` is a deliberate scrollback wipe) and the mouse DECSETs (tmux passes those through even with `mouse off`; stripping them would break htop/vim mouse support — which is exactly why the mouse strip is opt-in per CLI and not part of the narrow flavour). ⚠️ The `useMux` gate is load-bearing: `startShell()`/`startInteractive()` fall back to a DIRECT PTY when mux creation fails, and there the inner program's own `?1049h` really does reach xterm — stripping it would break vim/less/htop for real. The replay path (`session-routes.ts`, via `session.usesMux`) applies the same three branches; the frontend gate (`_shouldReportMouseToCli()`) keeps no mode list at all: it reads only the published `cliMouseTracking`, which the server sets solely in a DECSET-stripping branch (full or mouse), so it can only be true for a mode whose DECSETs are stripped — the modes where the browser's hand-encoded tap (`_sendSyntheticSgrTap`) is the only way a click still reaches the CLI. Whichever modes the registry strips, the browser follows (pinned in `test/claude-scrollback-strip.test.ts`). The chunk-boundary carry (`_altScreenSeqCarry`) runs for all three flavors. Tests: `test/claude-scrollback-strip.test.ts`, `test/terminal-touch-tap.test.ts`.
⚠️ **What a mouse-DECSET strip (full or mouse) removes, it must REMEMBER.** Stripping the mouse DECSETs means xterm's `modes.mouseTrackingMode` is permanently `'none'` for those modes, so the browser hand-encodes click reports to compensate (`_sendSyntheticSgrTap`). With no state to consult it did that on EVERY click, which delivered mouse reports to programs that never asked for them: the same pane runs a plain shell whenever the CLI has exited or a `shell` was started inside a claude-mode session, and a shell prints the report as literal text (`[<0;88;20M`), garbling the next line typed. `_recordStrippedMouseMode()` therefore records each stripped sequence as it goes and publishes `cliMouseTracking` through `toState()`, and `_shouldReportMouseToCli()` requires it. ⚠️ Only the TRACKING modes count (1000/1001/1002/1003): 1005/1006 select an ENCODING and 1007 is alt-scroll, and counting those would put the stray reports straight back. ⚠️ The change broadcasts IMMEDIATELY rather than through `broadcastSessionStateDebounced`, because the flag flips when a dialog opens and the user can click that dialog inside the 500ms debounce window. Measured on a live claude 2.x: the CLI holds a tracking mode on continuously in fullscreen (so clicks keep being reported exactly as before; the default inline renderer holds none, measured on 2.1.283 even with `/model` open), while a bash prompt in the same stripped mode reports nothing. Fails toward silence: after a server restart the flag is false until the CLI re-emits, which tmux does at client attach.
@@ -308,7 +310,7 @@ So: `_confirmIdle()` (session.ts) requires the pane to go quiet, and then asks t
⚠️ **The workspace-trust dialog is not a composer.** Its `❯` sits on `No, exit`, so it arms the confirmation, and the dialog is quiet with no working line, so a fresh case used to be concluded idle about 4 s after launch while the dialog was still up (measured on Claude Code 2.1.294: `GET .../wait?until=idle` answered `idle` at 3.8 s in 6 of 12 fresh-dir launches). An agent that waited for idle and then prompted typed into the dialog, and the prompt's Enter confirmed `No, exit`: the pane died and the wait reported `exit`. `_probePaneWorking()` therefore also records whether its capture shows `isTrustDialogScreen()` (a failed capture keeps the last answer, since it proves nothing), and `_confirmIdle()` re-checks on `PANE_PROBE_RECHECK_MS` instead of concluding while it does, but only inside the auto-accept's own `TRUST_DIALOG_WINDOW_MS`: a later screen that merely quotes the dialog must not hold a session busy. The status is left alone rather than marked working, since nothing is running. Tests: `test/session-trust-dialog.test.ts`.
⚠️ **The composer glyph and the working line are per-CLI registry DATA** (`capabilities.workDetect`, #385), not Claude constants: claude declares `❯` plus the pattern above, codex declares `›` plus `[Ee]sc to interrupt`, pi declares its composer rule `─` plus the spinner it embeds there, opencode declares its composer bar `┃` plus its 8-cell footer spinner `[⬝■]{8}`, omp declares its input row `╰─` plus the spinner and elapsed time that replace the `π` in its status bar, gemini declares its composer's bottom bar `▀` plus its `(esc to cancel, <n>s)` spinner line, antigravity declares the head of its footer hint `? for shortcuts` (`? for sh`, drawn only at rest, so nothing arms the idle check during a turn and a pending shell approval reads as working until it is answered; the composer's `>` is also the echoed prompt's) plus its 8-dot braille spinner row `⣯ Generating...`, and a CLI that declares neither falls back to Claude's pair, which is what every session used before the registry carried one. Before that, this whole mechanism was gated Claude-mode-only on the reasoning that an external CLI has no `❯`, which was true and still left every Codex session reporting `idle` for its entire life.
⚠️ **The composer glyph and the working line are per-CLI registry DATA** (`capabilities.workDetect`, #385), not Claude constants: claude declares `❯` plus the pattern above, codex declares `›` plus `[Ee]sc to interrupt`, pi declares its composer rule `─` plus the spinner it embeds there, opencode declares its composer bar `┃` plus its 8-cell footer spinner `[⬝■]{8}`, omp declares its input row `╰─` plus the spinner and elapsed time that replace the `π` in its status bar, gemini declares its composer's bottom bar `▀` plus its `(esc to cancel, <n>s)` spinner line, antigravity declares the head of its footer hint `? for shortcuts` (`? for sh`, drawn only at rest, so nothing arms the idle check during a turn and a pending shell approval reads as working until it is answered; the composer's `>` is also the echoed prompt's) plus its 8-dot braille spinner row `⣯ Generating...`, jcode declares the bare `>` of its `<n>> ` composer (the one cell its turn-ending repaint redraws) plus a braille spinner row carrying an elapsed time, and a CLI that declares neither falls back to Claude's pair, which is what every session used before the registry carried one. Before that, this whole mechanism was gated Claude-mode-only on the reasoning that an external CLI has no `❯`, which was true and still left every Codex session reporting `idle` for its entire life.
⚠️ `workingLine` is config-supplied (a user `clis.json` can set it) and the compiled pattern runs on the PTY hot path, so it goes through `compileVersionRegex()` in BOTH the schema refine and `_workingLinePattern()`: a nested quantifier there is a ReDoS against the event loop, and the helper returns null rather than throwing so the fallback is structural.
@@ -720,19 +722,11 @@ Either way the actual launch (`runCustomModelEntry`) routes through `run()` itse
**Remove** stops the child, undoes the connection FIRST (a refused write of the endpoint store or of the settings rejects before any file is deleted), restores the flag only when this feature switched it on AND no other endpoint is saved, then deletes the runtime and the private home and, when asked, the models (`removeLocalModelFiles()`, which refuses any path outside the root). ⚠️ While a live session uses the endpoint the answer is 409 `sessions-open` with the session names (`sessionNamesUsingEndpoint()`, read at the moment of the request). ⚠️ The coding agent stays: what its installer wrote is not ours to remove.
**The library** (one model in use, several on disk). `LocalModelStateFile.library` lists every model that PASSED the check through Codeman: `{ id, label, context, digest, bytes?, cli?, verifiedAt? }`, no path. The service upserts the entry as a fact when `verify` passes (where it stores the digest); the reducer only carries the list, through every event but `remove`. An `installed` file an earlier build wrote has no list: `libraryOf()` then counts its one model, and an install or a switch from it writes that model into the list before the state's `model` member becomes the new job's. ⚠️ ONE model is in use: the endpoint record still lists exactly one model, the runtime still holds one (`OLLAMA_MAX_LOADED_MODELS=1`), and `launchRefusalMessage()` still starts only the state's model with its stored digest. Never widen the record to "every checked model": the port has no login, and two sessions on two models would make the runtime swap 20 GB on every request.
⚠️ Three verbs, each a row of the tables and never a special case. **Add**: `install` is also listed for `installed` in `INSTALL_TRANSITIONS`; the job is the same five steps, and the earlier model stays (the `replacedModelId` rule, `replacedModelFor()`, never names a model of the library: only an unchecked or partial download is deleted as a leftover). **Switch**: the event `use` (`installed`, `paused`, `failed`) starts a job of `USE_JOB_STEPS` (`verify`, `connect`) for a library entry, carrying its stored digest in `model.digest`; `stepVerify` compares that digest BEFORE it loads anything and again after the check, and a different or missing one fails with `model-changed`. A switch has no `model` step, so the leftovers of the job it replaced are deleted at the start of `verify` (`dropReplacedModel()`). Its coding agent is the entry's own when that is installed now, else the state's (`recheckCliInstalled()`), else a 400: a job that stopped while it installed another agent leaves the state naming one that is not there. **Delete** (`deleteLibraryModel()`) moves no state, so it is not an event; whether a state allows it is the `delete-model` action of its `INSTALL_STATE_TABLE` row. It takes a model NAME (the request schema and the service both hold it to `LOCAL_MODEL_ID_PATTERN`), requires that name in the server's own listing (the library or the runtime's tags), goes through the runtime's `/api/delete` only (never a file delete, never a path), and trusts only the runtime's answer that the model is gone, never an empty listing. ⚠️ It refuses the model of the installation or of the current job (`model-active`), a running job, and a runtime that is down or does not answer (`runtime-not-running`).
⚠️ `install` from `installed` and `use` are refused with 409 `sessions-open` while live sessions use the endpoint, like Stop: both change which model the runtime holds and often restart it for another context. ⚠️ While a job that started from `installed` is unfinished (`installing`, `paused`, `failed`) the launch check refuses every start with `job-unfinished` (a code only a refused start says, like `runtime-not-running`), because it wants the state `installed`; the view then offers `use` on the other checked models, which is the way back. A switch is never offered for the model of the stopped job itself: Resume goes on with that one. ⚠️ Both verbs undo a Stop (`undoStop()`, `runtime.enabled` back to true) once their job started: the job runs the runtime, and a model that is in use and running must also start with the server again.
**The view's two listings.** `install.library` (`buildLibraryRows()`, pure) joins the state's entries with what the RUNNING runtime lists (`/api/tags`): `ready`, `changed` (another digest under the name), `missing` (no longer listed) for a checked model, `unverified` for anything else the runtime lists (a model an earlier Remove kept, the model of a job that has not passed its check, one another program pulled through the unauthenticated port), which can be deleted and never switched to. A name that is not a plain `name:tag` is listed with no action. `install.store` is `measureModelStore()`: the sizes of the regular files directly in `models/blobs`, nothing followed, nothing outside the root. ⚠️ Neither is asked for on the one-second poll: `tagsCache` (5 s) and `storeCache` (30 s) are refreshed in the background, only a figure that was never read is waited for (300 ms at most), a runtime that does not answer keeps the last list (or every checked model would read as `missing` for a moment), and every verb and finished step that changes the store throws both away (`forgetStore()`). ⚠️ In `idle` there is no runtime to ask, so the list is empty and only `store` says that an earlier Remove kept models; `remove` is listed there only while it holds something, and `DELETE /api/local-model { removeModel: true }` is what frees it. `Candidate.library` (`active`, `ready`) is added by the ROUTE (`markLibraryCandidates()`), never by `recommend()`, which stays pure and knows nothing of what is installed.
**Developer tools**: `checkDeveloperTools()` runs ONLY `/usr/bin/xcode-select -p`, bounded, on darwin. ⚠️ Never `git`, `python3` or `xcrun` from the server: on a Mac without the tools each opens a system window. The view carries `developerTools`, and the window prints the one sentence when it is `missing`.
**The endpoint store's writer** (`src/custom-model-hosts-writer.ts`): `mutateCustomModelHosts(fn)` is the shape of `mutateSettingsFile()`: one promise chain, a random temp name, a refusal (409) to write over a file that does not parse. ⚠️ EVERY writer of `custom-model-hosts.json` goes through it: the four endpoint routes, the sweep and the connect step (`test/custom-model-hosts-writer.test.ts` fails a `writeCustomModelHosts()` call anywhere else in `src/`). The routes used to read, change and write on their own through one shared temp name, so two creates sent together lost a record or failed, and a file that did not parse was read as `[]` and replaced by one record, with every other endpoint's API key gone. ⚠️ A route's check runs INSIDE the callback, and discover splices onto the record as it is after the probe, not the copy read before it.
Tests: `test/local-model-install-job.test.ts` (the tables and the reducer, every state with every event, the library rows), `test/local-model-install-service.test.ts` (the three library verbs and the two caches included), `test/local-model-install-e2e.test.ts` (the real modules over the fake runtime: install A, add B, switch back, delete B), `test/local-model-state-store.test.ts`, `test/local-model-connect.test.ts`, `test/custom-model-hosts-writer.test.ts`, `test/cli-install-runner.test.ts`, `test/routes/local-model-routes.test.ts`, `test/routes/local-model-launch-guard.test.ts`, and the runtime's own `test/local-model-runtime.test.ts`, `test/local-model-ollama-client.test.ts`. The fake runtime is `test/fixtures/fake-ollama.ts`.
Tests: `test/local-model-install-job.test.ts` (the tables and the reducer, every state with every event), `test/local-model-install-service.test.ts`, `test/local-model-install-e2e.test.ts` (the real modules over the fake runtime), `test/local-model-state-store.test.ts`, `test/local-model-connect.test.ts`, `test/custom-model-hosts-writer.test.ts`, `test/cli-install-runner.test.ts`, `test/routes/local-model-routes.test.ts`, `test/routes/local-model-launch-guard.test.ts`, and the runtime's own `test/local-model-runtime.test.ts`, `test/local-model-ollama-client.test.ts`. The fake runtime is `test/fixtures/fake-ollama.ts`.
### Approvals Inbox
+9 -3
View File
@@ -1,6 +1,6 @@
# The CLI registry
Every run mode Codeman can launch — Claude Code, Terminal/Shell, OpenCode, Codex, Gemini, Antigravity, Pi, Grok, DeepSeek Harness, OMP and GitHub Copilot CLI — is a `CliEntry`: a data record describing how to find the binary, how to build its command line, what environment it needs, and what it can do. Code that used to ask "which CLI is this?" asks the entry instead.
Every run mode Codeman can launch — Claude Code, Terminal/Shell, OpenCode, Codex, Gemini, Antigravity, Pi, Grok, DeepSeek Harness, OMP, GitHub Copilot CLI and jcode — is a `CliEntry`: a data record describing how to find the binary, how to build its command line, what environment it needs, and what it can do. Code that used to ask "which CLI is this?" asks the entry instead.
## Where it lives
@@ -227,7 +227,9 @@ still reading. A caller that asked only for `idle` keeps getting `idle`.
| --- | --- |
| claude | `.claude/skills/codeman` |
| codex, gemini, opencode, antigravity, grok, deepseek, omp | `.agents/skills/codeman` |
| pi, shell | none |
| pi, shell, jcode | none |
jcode reads `.agents/skills` too, but it is left out (`agentSkillInjection: false`): its tools run in one shared per-user daemon with the CODEMAN_* identity of whichever session started it, so an injected skill would act as that session ([jcode-integration.md](jcode-integration.md#the-shared-daemon)).
Measured 2026-10-11 against the installed builds or their source: every CLI in the middle row scans `<workspace>/.agents/skills/` (gemini only in its cwd, dsh only at the git root, else cwd), and Claude Code does not read it, so a case shared with a claude session lists the skill once there (opencode, grok and omp read both directories and see the same skill twice, identical, under one name). ⚠️ pi is left out on purpose: any project-level `.agents/skills` or `.pi/skills` makes pi ask "Trust project folder?" at startup, which would stall a spawned worker on a dialog; it reads the user-level `~/.agents/skills` without the prompt (`npx skills add Ark0N/Codeman --skill codeman -g`). A codex, gemini or other session's copy in a case that pi sessions also use triggers that prompt once for the human there. ⚠️ `agentSkillDir` is a closed enum (`AGENT_SKILL_DIRS`, schema.ts), never a free path: `clis.json` can set it and it decides where the server writes. ⚠️ Two codex traps the skill names rather than fixes: codex's default sandbox blocks network even to loopback (the skill needs **Bypass approvals and sandbox**), and codex 0.162 runs tool commands through a shared `app-server` daemon whose environment (`CODEMAN_SESSION_ID`, `CODEMAN_API_URL`) is that of the session that started it.
@@ -235,6 +237,10 @@ Measured 2026-10-11 against the installed builds or their source: every CLI in t
`capabilities.newline` (`'line-feed'` | `'esc-enter'`, absent = line feed) is the byte sequence the `send-key` route types into the pane for Shift+Enter. A line feed (`0x0a`, also Ctrl+Enter) is what Claude Code's Ink input reads as "insert a newline"; `esc-enter` (`ESC CR`, the Option/Alt+Enter chord) is there for a composer that ignores a bare line feed. No stock CLI declares it today: the bytes are typed by tmux on the server, so the browser's OS cannot change what a CLI reads, and Codex 0.147.0 was checked to take a line feed (a Shift+Enter that submits is the keypress leak fixed in #520, not a byte problem). A user `clis.json` can set it for a CLI that needs it. It is an enum rather than a byte string on purpose: config never carries bytes that get typed into a pane. Settings → Terminal & Input → **Key tester** prints what a browser reports for keydown/keypress/keyup, to see whether a device is sending what you think.
## A CLI with a shared daemon
`capabilities.sharedDaemon: true` says the CLI's TUI is a client of one per-user daemon that its first client starts (in its own session and process group) and every later session shares. jcode is the one stock entry that declares it. A session kill normally signals every descendant of the pane by parent pid, and the daemon is still a descendant of the pane that started it, so closing that one tab cut every other session of the CLI off (measured with jcode). With the flag set, `killSession` signals only the descendants still in the pane's own process group (`descendantsInPaneGroup()` in `src/proc-tree.ts`, one bounded `ps -o pid=,pgid=` lookup; a lookup that fails keeps the full kill). `test/proc-walk-bounds.test.ts` pins that jcode alone declares it, so no other CLI's kill changes.
## Arg-template safety
The composed command line is interpolated into `bash -c "…"` inside tmux, which makes command construction a security boundary. Four independent layers keep config out of it:
@@ -368,7 +374,7 @@ A module-level const freezes at first import, and the failure is asymmetric: a C
## MCP server sync
`capabilities.mcpConfig` (`{ path, format, relocation? }`, `path` relative to the home directory) names the file a CLI keeps its user-level MCP server list in and the dialect it is written in. `src/mcp-sync.ts` reads that list from every ENABLED CLI that declares one, and that is installed or already has the file (a CLI that is neither is reported `absent`, never created), and adds any server a CLI is missing from the others. It writes other tools' own config, so it is **opt-in**: `mcpSyncEnabled` (synced, default OFF) gates `GET`/`POST /api/mcp-sync` (403 while off) and the Settings → Agents & CLIs → MCP servers controls. Declared today for claude, gemini, codex, opencode and antigravity (and GitHub Copilot CLI, below); every format was checked against what the CLI's own `mcp add` writes, except opencode's (documented, not installed to check). A CLI with no entry (pi, grok, omp, deepseek) is not guessed at: it is listed as `unsupported` in the result when enabled. Adding one is a registry entry plus a small adapter in `mcp-sync.ts`, and a verified fixture in `test/mcp-sync.test.ts`.
`capabilities.mcpConfig` (`{ path, format, relocation? }`, `path` relative to the home directory) names the file a CLI keeps its user-level MCP server list in and the dialect it is written in. `src/mcp-sync.ts` reads that list from every ENABLED CLI that declares one, and that is installed or already has the file (a CLI that is neither is reported `absent`, never created), and adds any server a CLI is missing from the others. It writes other tools' own config, so it is **opt-in**: `mcpSyncEnabled` (synced, default OFF) gates `GET`/`POST /api/mcp-sync` (403 while off) and the Settings → Agents & CLIs → MCP servers controls. Declared today for claude, gemini, codex, opencode and antigravity (and GitHub Copilot CLI, below); every format was checked against what the CLI's own `mcp add` writes, except opencode's (documented, not installed to check). A CLI with no entry (pi, grok, omp, deepseek, jcode) is not guessed at: it is listed as `unsupported` in the result when enabled. Adding one is a registry entry plus a small adapter in `mcp-sync.ts`, and a verified fixture in `test/mcp-sync.test.ts`.
**Tools that are not run modes, or that declare no `mcpConfig`.** `src/mcp-sync-targets.ts` declares such tools as plain data (`MCP_SYNC_ONLY_TOOLS`: id, label, config path, dialect, relocation var, the binary whose presence means "installed"). They join the registry CLIs as sync targets (listed after them, so a registry CLI's definition wins a same-name difference), under the same rules: installed or already configured, otherwise `absent`. GitHub Copilot CLI is both: its registry entry declares `mcpConfig`, so while the run mode is enabled the sync-only row is skipped, and it applies only if the run mode is disabled. Copilot's dialect is `copilot-json` (`~/.copilot/mcp-config.json`, relocated by `COPILOT_HOME`; checked against `copilot mcp add` 1.0.94): `mcpServers`, each entry with `tools` (`["*"]` = all), `type` `local` | `http` | `sse`, `command`/`args`/`env` or `url`/`headers`. `copilot mcp disable` does not mark the entry: it lists the name under `disabledMcpServers` in `settings.json` beside the config. Sync reads that list (never writes it) so a disabled server is not copied, and reports the target `unreadable` if `settings.json` is not valid JSON rather than guessing.
+6 -3
View File
@@ -34,8 +34,8 @@ must not change what is inside an image tagged `codeman/agent:base`, or two mach
that tag hold different images and every cache decision downstream is a lie. Each entry's
`enabled` flag IS honoured, so a CLI that ships disabled is never baked in.
Five CLIs keep hand-written layers, for two different reasons that are easy to conflate.
`antigravity`, `grok` and `omp` declare no `npmPackage` at all, so they never enter the shared
Six CLIs keep hand-written layers, for two different reasons that are easy to conflate.
`antigravity`, `grok`, `omp` and `jcode` declare no `npmPackage` at all, so they never enter the shared
npm layer and each gets a vendor-installer layer instead. `pi` and `deepseek` ARE on npm but
carry `discovery.install.agentImageLayer` in `stock.ts` (a REGISTRY field, rather than an
id-keyed table duplicated between the two producers of the image's build args), which pulls
@@ -47,6 +47,7 @@ them out of the shared layer because a plain `npm install -g` is not enough for
| `deepseek` | Needs `pnpm` alongside it (`dsh plugin`, issue #352) plus a `dsh-tui` profile install. |
| `antigravity` | Not on npm — Google ships a standalone binary (~190MB, the largest layer). |
| `grok`, `omp` | Not on npm — standalone vendor installers. |
| `jcode` | Not on npm: jcode.sh's installer, then the WHOLE version directory copied to `/opt/jcode` (the launcher is an `sh` script that execs the binary beside it). |
`test/docker-agent-image-coverage.test.ts` requires every special case to carry a written
reason AND still be present in the Dockerfile, so an exclusion cannot silently become an
@@ -62,7 +63,7 @@ A zero exit code only proves the layers ran, not that the toolchain works. Verif
```bash
docker run --rm codeman/agent:base bash -lc \
'for c in claude codex gemini opencode agy pi grok dsh omp; do printf "%-9s " $c; $c --version 2>&1 | head -1; done'
'for c in claude codex gemini opencode agy pi grok dsh omp jcode; do printf "%-9s " $c; $c --version 2>&1 | head -1; done'
```
⚠️ `dsh --version` is the one line above that answers a different question than the
@@ -81,6 +82,8 @@ Antigravity (`agy`) and Grok (`grok`) are the two CLIs not installed from npm (G
Pi's credentials are seeded per-FILE rather than as a whole directory (`auth.json`, `settings.json`, `trust.json`, `models.json`, `models-store.json` out of `~/.pi/agent`), because that directory also holds `sessions/`, `extensions/`, `skills/` and the installed package trees — gigabytes on an active host. Consequence: in-container pi sessions are invisible host-side, so `pi -c` inside a Docker case only sees that container's own history. See [`pi-integration.md`](./pi-integration.md). Grok is seeded per-file for the same reason (`auth.json`, `config.toml`, `pager.toml` out of `~/.grok`, which also holds `sessions/`, `memory/` and the ~160MB binary under `downloads/`), with the same consequence for `grok -c`. See [`grok-integration.md`](./grok-integration.md). OMP is the one CLI in this family where `sessions/` is the EXCEPTION rather than the rule: `~/.omp/agent/{config.yml,mcp.json,models.yml,settings.yml}` are seeded per-file (the dir also holds SQLite caches and `terminal-sessions/`), but `~/.omp/agent/sessions/` is shared RW like codex's, not seeded, because Codeman reads it host-side for history recovery and `--resume` pinning. See [`omp-integration.md`](./omp-integration.md).
jcode's credentials are not seeded at all: `~/.jcode` keeps its OAuth tokens beside ~160MB of builds, its sessions and SQLite caches, and its API keys live in `~/.config/jcode`, so there is no small set of files to copy. Sign in to jcode inside the container (`docs/jcode-integration.md`).
The image can also carry the GitHub CLI (`gh`) and the Azure CLI (`az` + the `azure-devops` extension, in `AZURE_EXTENSION_DIR=/opt/az-extensions` so it stays out of the seeded HOME), wired into the system git config as credential helpers for github.com and dev.azure.com / *.visualstudio.com, exactly as in `docker/server.Dockerfile`. Their sign-ins are seeded per-FILE like pi's: `~/.config/gh/{hosts.yml,config.yml}` and `~/.azure/{azureProfile.json,msal_token_cache.json,service_principal_entries.json,clouds.config,config}`, never `~/.azure`'s logs, command index or extensions. A token kept in a desktop keyring, or in the encrypted MSAL cache az uses on Windows/macOS, is not in those files and does not carry. None of the three is version-pinned; the `--no-cache` rebuild recommended above is also what refreshes them. Both CLIs are opt-in and OFF by default: `CODEMAN_AGENT_IMAGE_INSTALL_GH=1` / `CODEMAN_AGENT_IMAGE_INSTALL_AZ=1` in the environment of `scripts/build-agent-image.mjs`, or of the Codeman server for its own auto-build (in the Compose deployment, `environment:` in `docker-compose.override.yml`), become the `CODEMAN_INSTALL_GH` / `CODEMAN_INSTALL_AZ` build args and put that CLI, its extension and its helper entry into the image. Unset passes nothing, so a default build's argv is unchanged and the image has neither. The sign-in seeds follow the same switches, read when a case container is created: `.config/gh` only with `CODEMAN_AGENT_IMAGE_INSTALL_GH=1`, `.azure` only with `CODEMAN_AGENT_IMAGE_INSTALL_AZ=1` (`enabledByEnv` in `CRED_STORES`), never merely because the files exist. Seeds are create-time mounts and deliberately not part of the config hash (hashing them would trip the drift gate for every case), so an existing case container picks them up only when it is recreated.
Set `CODEMAN_AGENT_IMAGE_GIT_USER_NAME` and `CODEMAN_AGENT_IMAGE_GIT_USER_EMAIL` together to configure the agent image's Git identity. Rebuild an existing `codeman/agent:base` with `node scripts/build-agent-image.mjs --no-cache`, then recreate Docker-case containers so they use the rebuilt image.
+277
View File
@@ -0,0 +1,277 @@
# jcode sessions
Codeman can drive [jcode](https://github.com/1jehuang/jcode) (1jehuang/jcode, MIT, a
coding-agent TUI written in Rust) as a session backend, alongside Claude Code, OpenCode,
Codex, Gemini, Antigravity, Pi, Grok, DeepSeek, OMP and GitHub Copilot. `jcode` is its own **run mode**:
its own PTY, its own tmux session, its own tab identity (amber, `JC` badge, a plain ring
standing in for jcode's halftone donut). It is not a location overlay like Docker or
remote-SSH cases, and it is not a web tab.
Everything here was measured against jcode v0.98.1 on 2026-10-16 (see
[How it was measured](#how-it-was-measured)). The registry entry in
`src/config/cli-registry/stock.ts` carries the same facts in its comments and is the
source of truth when the two disagree.
## Install
```bash
curl -fsSL https://jcode.sh/install | bash
```
The installer downloads a checksummed release binary into
`~/.jcode/builds/versions/<version>/` and links `~/.local/bin/jcode` (its
`JCODE_INSTALL_DIR` default) to it. The linked `jcode` is a small `sh` **launcher** that
resolves its own path and execs the `jcode-<os>-<arch>.bin` beside it, so the version
directory is what matters, not the single file the link points at.
Codeman resolves the binary through the generic registry resolver (there is no
jcode-specific resolver module): the server PATH, then `~/.local/bin`, `/usr/local/bin`
and `~/bin`, then an interactive login shell. npm carries an unrelated package also called
`jcode`, so a PATH hit is not trusted on its own: `jcode --version` must print
`jcode v<semver>` (the real one prints `jcode v0.98.1 (1c7426eb1)`), and anything else
counts as absent. Check what it resolved:
```bash
curl -s localhost:3000/api/jcode/status | jq
# { "available": true, "path": "/home/you/.local/bin", "version": "0.98.1" }
```
## Authenticate
jcode keeps its own logins: OAuth accounts in `~/.jcode/auth.json`, API keys in
`~/.config/jcode/<provider>.env`, the default provider and model in `~/.jcode/config.toml`.
Set them up with jcode itself (`jcode login`, `/login` inside the TUI, or
`jcode provider add <name> --base-url ... --model ...` for an OpenAI-compatible server).
jcode can also offer to reuse logins other CLIs left behind; it asks before reading any of
them. Codeman forwards no credentials into a jcode pane.
## What Codeman wires up
`JcodeConfig` (per session, persisted in `state.json`, round-trips through respawn and
server restart):
| Field | Flag | Notes |
| ----------------- | --------------- | ---------------------------------------------------------------------------- |
| `model` | `--model <v>` | e.g. `claude-opus-4-8`, `gpt-5.5` |
| `resumeSessionId` | `--resume <v>` | a session id (`session_fox_1792160401272_f5b47ce47d631006`) or its short name (`fox`) |
Both values are regex-validated at the route and checked again by the argv engine, and a
value that fails is **dropped**, never escaped into the command. `resumeSessionId` uses the
`path-segment` pattern (a leading letter or digit) on purpose: jcode's `--resume` takes an
OPTIONAL value (a bare `--resume` opens its session picker), so a value starting with `-`
would be parsed as a flag of its own (`--resume --no-update` is two flags).
The Run button sends **no** `jcodeConfig`: jcode has no approval prompts and no bypass
flag, so there is nothing to opt into (the same shape as pi). A bare spawn is just `jcode`.
There is **no** `envOverrides` prefix for jcode: `JCODE_*` is not allowlisted. See
[The shared daemon](#the-shared-daemon) for why.
## The shared daemon
Every jcode TUI is a CLIENT of one shared per-user daemon (`jcode serve`). The first client
starts it, in its own session and process group (`setsid`), and every later client attaches
to it. It stays the first client's CHILD by parent pid, though, which is what the
[session kill](#closing-a-tab-never-takes-the-daemon-down) has to account for. The agent and its tools run in the DAEMON, with the daemon's
environment. Measured: a second client started with `CODEMAN_PROBE=second-client` asked its
bash tool to `echo "probe=[$CODEMAN_PROBE]"` and got `probe=[]`.
Three things follow, and each one is a deliberate decision:
- **The agent skill is not injected.** jcode does read `./.agents/skills` (and
`./.jcode/skills`, `./.claude/skills`), but a jcode agent's tools see the
`CODEMAN_SESSION_ID`, `CODEMAN_API_URL` and `CODEMAN_MUX` of whichever session started
the daemon, or none at all if it was started outside Codeman. An injected skill would act
as THAT session: spawn children under the wrong parent, prompt another session's
workers. A user-level copy of the skill (`~/.agents/skills`, which jcode also reads) has
the same problem; this is the same trap as codex's shared `app-server`, only it is jcode's
normal case rather than an edge.
- **No `JCODE_*` envOverrides prefix.** Most of that namespace is read by the daemon, so a
per-session value would do nothing, and where it did act (in the session that happens to
start the daemon) it would reach every jcode session on the machine. The namespace
includes variables that run code or redirect traffic: `JCODE_HOOK_*`, `JCODE_SPAWN_HOOK`,
`JCODE_*_COMMAND`, `JCODE_*_API_BASE`. Configure jcode in `~/.jcode/config.toml`, or in
the Codeman server's own environment.
- **Deleting a Codeman jcode session leaves the daemon running**, by design: the other
sessions live there (next section). `jcode server stop --force` stops it by hand.
### Closing a tab never takes the daemon down
Codeman's session kill signals every descendant of the pane (by parent pid), so it used to
kill the shared daemon whenever the closed tab happened to be the one that started it.
Measured on a beta before the fix: two jcode sessions, the first one deleted, and the second
one's client looped on `Connection lost - retrying (attempt 9) - server closed the
connection` with its prompt queued (`⏳`), while a send-and-wait on it timed out.
The jcode entry declares `capabilities.sharedDaemon: true`, and for such a CLI the kill
signals only the descendants still in the pane's own process group
(`descendantsInPaneGroup()`, `src/proc-tree.ts`, one bounded `ps -o pid=,pgid=`). The daemon
left that group when it called `setsid`, and everything it runs inherits its group, so the
daemon and its tools are spared while the client and anything else in the pane's group are
killed as before. A lookup that fails keeps the old behaviour (kill every descendant). Every
other CLI is unaffected. Measured after the fix: the kill logs `Sparing 1 process(es)
outside pane <pid>'s group (the CLI's shared daemon)`, the daemon keeps its pid, and the
remaining session answered a send-and-wait in 5 s.
The flip side: a process a jcode agent started (a dev server, a watcher) is a child of the
daemon, not of the pane, so closing the tab does not stop it. That is how jcode behaves in a
plain terminal too.
## Approvals and multi-user mode
jcode has no approval dialog. Its bash tool runs ungated apart from jcode's own model-side
reflection gate on risky commands and a hard deny on catastrophic paths. So there is no
bypass flag to clamp, nothing for the multi-user clamp to force off, and no dialog for
Codeman to read as `blocked`. In multi-user mode a jcode session is like a pi session: an
agent that runs tools without asking, under the one OS account every session shares.
## Terminal behavior
jcode is a **full-screen alternate-screen TUI with mouse tracking on** (tmux
`alternate_on=1`, `mouse_any_flag=1`, `mouse_sgr_flag=1`). It takes the opencode strip,
`altScreen: 'strip-mux-and-mouse'`: the mouse DECSETs are stripped, so a drag selects text
locally (Auto Copy and copy-on-select work) instead of being reported to jcode.
Scrolling: jcode pages its own transcript on PageUp/PageDown and also scrolls on SGR wheel
reports (both measured). It declares `transcriptPageKeys: true`, so over the hollow
alt-screen buffer a wheel or a swipe becomes PageUp/PageDown instead of doing nothing.
Input: a long prompt sent the way Codeman sends one (a bracketed paste, then a separate
Enter) submits cleanly (measured with a 170-character prompt).
### How Codeman reads a working pane
At rest the composer row is `<n>> ` (the prompt number, then `>`); a submitted prompt is
echoed into the transcript as `<n>› <text>`. While a turn runs the composer row reads
`<n>…` and a status row appears directly above it: a braille spinner (the gemini/omp frame
set, about 12 fps), then the phase and the elapsed time:
```
⠋ sending… 0s
⠦ thinking… 2s · https
⠼ waiting for response… 3s · https
⠸ running bash · <detail> · 12s · Alt+B bg
⠼ 12s · 45.1 tps · ↑16k ↓20 (streaming: the time comes first)
↻ network disconnected, waiting to retry · … · 5s
```
Past a minute the time reads `1m 3s`, past an hour `1h 2m`. At rest that row is empty or a
dim tip, never a spinner, and the turn leaves a summary row (` 21s · 199.6 tps · ↑17k ↓2k`)
that has a time but no spinner. The `workingLine` pattern therefore needs a spinner (or the
`↻ network disconnected` row) at the start of a row AND an elapsed time on it.
The composer glyph is the bare `>`. tmux redraws only the cells that changed, and on the
composer row at the end of a turn that is the `…` turning into `>` (the recorded client
stream carries `\x1b[43;2H...> `), never the prompt number, so `>` is what reliably arrives.
Mid-turn stream chunks never contain it. The glyph only ARMS the idle check; the screen and
the silence decide.
Silence is a sound signal for jcode: the spinner repaints the pane at least every 100 ms for
the whole turn, and at rest jcode sends nothing at all (12 seconds recorded: zero bytes).
The submit verifier stays inert for jcode, which is what it wants (it has no early-Enter
defect): every composer row starts with its prompt number, so no screen row starts with
`>` the way the verifier requires.
### Reading a jcode worker's answer
`GET /api/sessions/:id/last-response` returns an **honest empty** (`text: ""`, and
`messages: []` with `?context=full`) for jcode. The pane segmenter the other TUIs use cannot
read a repainting alternate-screen stream (measured: it served jcode's welcome banner as
"the answer"), and without this the route would fall through to the Claude transcript scan,
which could adopt a Claude conversation from the same folder. Read a jcode worker's screen
instead:
- `codeman agent read <id> --tail 3000`
- MCP `read_output` with `source: "terminal"`
- `GET /api/sessions/:id/terminal?tail=3000`
There are no `stop`/`blocked` hook signals for jcode: wait for `idle`, or use `wait-output`
markers, as with every hook-less CLI.
## First run: the onboarding wizard
A fresh jcode install opens an onboarding wizard ("Welcome to jcode onboarding / Log in to
OpenAI?") instead of the composer, on every launch, until its launch counter passes 5 or
10 sessions exist. A prompt typed into the wizard is lost (measured: it wedged that client).
Before driving jcode from Codeman, run `jcode` once in a terminal and finish or skip the
wizard, or press Esc in the Codeman pane.
## jcode's own defaults Codeman does not change
- **Telemetry** is on by default in jcode (install count, version, OS, session activity,
tool counts; jcode's `TELEMETRY.md`). Opt out with `jcode telemetry disable`, or set
`JCODE_NO_TELEMETRY=1` in the Codeman SERVER's environment (it is not a per-session env
override, see above).
- **Auto-update** is on by default for release builds; `--no-update` is jcode's own flag and
Codeman does not pass it.
- **Self-dev mode**: jcode switches into it when started inside a checkout of jcode itself.
## Docker cases
jcode is not on npm, so the agent image installs it in a dedicated Dockerfile step through
jcode.sh's installer, then copies the WHOLE version directory to `/opt/jcode` (the launcher
execs the binary beside it, so copying only the linked file, as the grok and omp steps do,
would leave a launcher with nothing to run) and links `/usr/local/bin/jcode` to the launcher.
Root's `~/.jcode` is dropped in the same layer so the image does not carry the ~160 MB
binary twice. Verified in `node:22-bookworm-slim`, including `jcode --version` as the
unprivileged `agent` user. Rebuild with the mandatory `--no-cache`:
```bash
node scripts/build-agent-image.mjs --no-cache
```
No credentials are seeded into the container: `~/.jcode` holds the OAuth tokens beside the
builds, sessions and SQLite caches, and API keys live in `~/.config/jcode`. Sign in inside
the container instead.
## Remote SSH cases
`jcode` mode is routed through an interactive login shell
(`exec "$SHELL" -i -l -c 'jcode'`), because sshd's remote-command PATH does not include
`~/.local/bin`. Per-session config does not cross ssh and is rejected rather than silently
ignored; a host whose `jcode` lives elsewhere takes the per-host `commands.jcode` override.
## Not supported yet, and why
- **A transcript reader** (a real `last-response`, Response Viewer content). jcode writes
sessions as `~/.jcode/sessions/session_<name>_<ms>_<hex>.json` plus a `.journal.jsonl`,
which is easy to parse, but pairing a Codeman pane with ITS session file is ambiguous: the
session JSON's `last_pid` is the shared daemon's, not the pane's, so two jcode panes in
one folder cannot be told apart from the files alone.
- **Custom Model Endpoint Profiles** (`customModelInjection: unsupported`). jcode's provider
is resolved in the daemon, not the pane, so a per-session redirect has nothing reliable
to land on.
- **The model in tile headers** (`modelDetect`). The status bar names provider and model
(`qwen5090 qwen3.8 27b`), but its layout varies by provider and width and was not
measured enough to anchor a pattern on. A session shows its launch model, if any.
- **Hook-based `stop`/`blocked` signals.** jcode has lifecycle hooks (`session_start`,
`turn_start`, `turn_end`, `session_end`), but they run in the daemon too, with the
daemon's environment, so they cannot tell Codeman which session they belong to.
- **Ralph, respawn heuristics, token/CLI-info parsing and the `❯` readiness probe are off**
for jcode, as for every external CLI.
A per-session daemon (`jcode --socket <path>`) would give each pane its own environment,
but it gives up the shared daemon that is jcode's main design point (cross-session swarm
awareness, a few MB per extra session), so it was not done here.
## How it was measured
- jcode installed with its own installer into a scratch `HOME` with telemetry off
(`DO_NOT_TRACK=1`, `JCODE_NO_TELEMETRY=1`) and a short `XDG_RUNTIME_DIR` (the daemon's
Unix socket path must stay under the `SUN_LEN` limit), so nothing touched the real home.
- A provider set up with `jcode provider add` pointing at a local OpenAI-compatible server
(an RTX 5090 serving Qwen 3.8 27B), so no subscription login was involved.
- jcode run in a scratch tmux socket at 160x45; `tmux capture-pane -p` every 200 ms through
real turns (a plain reply, a bash tool call, a streamed answer); `pipe-pane` at rest; and a
tmux CLIENT attached through a PTY to record exactly what Codeman's PTY reads.
- PageUp/PageDown sent with `send-keys`; SGR wheel reports (`\x1b[<64;40;10M`) pasted raw.
- A second client with its own environment to prove where tools run.
- The Docker step run in a throwaway `node:22-bookworm-slim` container.
- End to end through an isolated beta Codeman built from this branch (its own
`CODEMAN_INSTANCE`, port and tmux socket): `POST /api/quick-start` with `mode: "jcode"`,
`/api/jcode/status`, send-and-wait (`wait: "idle"` returned after the turn, 12.7 s for a
turn with a 5 s tool call, not during it), two sessions with the first deleted, and a
Playwright pass over the Run menu entry, the `Run JC` button and the rendered transcript.
The fixtures in `test/jcode-mode.test.ts` are verbatim from these captures.
+12 -82
View File
@@ -4,9 +4,7 @@ Codeman can set up a coding model that runs on the Mac the server runs on: no ac
no API key, and the session's prompts and files go to a model on this Mac rather than a
cloud provider (the coding agent itself may still check for updates). One button on the welcome screen opens a window that
shows what fits this Mac, installs the choice and ends on a coding agent session that
runs on it. The same window then lists the models that are on this Mac with what each takes
on disk, adds another one, switches between them and deletes one
([The models on this Mac](#the-models-on-this-mac)).
runs on it.
**Experimental.** The routes and their payloads may change in any release
([versioning-policy.md](versioning-policy.md), "Experimental local model routes"). Apple
@@ -105,9 +103,7 @@ for a row it does not know. Its answer for each memory size is then pinned in
## What an install does, in order
The person picks a model, presses "Install on this Mac" and confirms the sizes once. The
server then runs one job of five steps. The same job adds a second model to an installed one
("Add a model"): the runtime is there already, so it downloads the model alone, and the
earlier model stays on disk. The window polls `GET /api/local-model` once a
server then runs one job of five steps. The window polls `GET /api/local-model` once a
second; the job runs in the server, so the window can be closed.
| Step | What it does |
@@ -248,13 +244,10 @@ Switching the setting on also starts that five-minute sweep for every other save
existing custom model launch. Before a coding agent is STARTED on that endpoint the server
checks, in this order:
- no job that changes the model in use is running or stopped: until an install or a switch
ended, nothing is started ("The model on this Mac is being changed and is not ready");
- the runtime child Codeman started is alive (whatever else answers on the port is not
ours);
- the digest the runtime reports for the model in use is the one stored when it was checked;
- the model asked for is the model in use. Another model that is on this Mac is not started
on the endpoint: switch to it in the window first.
- the digest the runtime reports for the installed model is the one stored at install time;
- the model asked for is the one that was installed and checked.
That check runs wherever a new agent process comes to be on the endpoint: a session created
with the model, a model applied to a running session (which restarts its agent), the attach
@@ -280,76 +273,22 @@ answer back from a file it writes in its own folder. The skill never installs a
answers a confirmation, the runtime still answers one request at a time, and while a worker it
opened is live, Stop and Remove are refused until it is closed.
## The models on this Mac
The model store can hold several models. **One of them is the model in use**: the one the
endpoint record `local-ollama` lists, the runtime is started for and a session can be
started on. The runtime holds one model in memory at a time (`OLLAMA_MAX_LOADED_MODELS=1`),
so this is what the Mac can really do, and it keeps every rule above: one record, one
model, one stored digest per model.
The installed screen lists what is on this Mac, the model in use first:
| A row says | It means |
| ------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| In use | The model sessions start on |
| Ready | Codeman downloaded and checked it. **Use** switches to it |
| Not checked | The runtime lists it and Codeman never checked it: a model an earlier Remove kept, or one another program pulled through the runtime's port. It can only be deleted |
| Changed | It was checked, and the runtime now reports another digest under its name. It is never started; delete it and install it again |
| Missing | It was checked, and the runtime no longer lists it. **Delete** drops the entry |
Each row shows the context the model was checked with and its size on disk, as the runtime
reports it. Under the list is what all downloads take together: the size of the files in
`local-model/models/blobs`, so layers two models share are counted once and an interrupted
download is included. The window asks the runtime for the list at most every five seconds
and measures the store at most every thirty, never on every poll.
- **Add a model** opens the chooser. A model that is not on this Mac is installed like the
first one, with its sizes confirmed; when the job ends it is the model in use, and the
earlier model stays in the list. A row that is on this Mac already says so and is switched
to, without a download and without a confirm screen.
- **Use** switches to a checked model. It is a job of two steps, `verify` and `connect`:
the runtime is restarted with the context the model was checked with, the model is loaded
and checked again (the tiny chat, the GPU, the tool probe), its digest must still be the
stored one (else the switch fails with `model-changed` before the model is loaded), and
the endpoint record is rewritten. The coding agent is the one the model was checked with
when it is still installed, else the one of the installation.
- **Delete** removes one model through the runtime's own delete, after one question in the
row. The request carries the model's name, never a path, and the server deletes only a
name of its own listing. The model in use cannot be deleted: switch first, or Remove. It
needs the running runtime, so a stopped model shows no Delete until Start.
Adding a model and switching are refused while a session uses the model ("Sessions are
still using this model"), like Stop: their coding agents were started with the other model's
name, and the runtime would swap models on every request.
While a job that started from an installed model runs, or after it stopped (paused or
failed), no session is started: the checks above want a finished installation, and the
refusal says so (`job-unfinished`). The window
then lists the checked models with **Use**, which is the way back to the earlier one. Going
back deletes what the stopped job left of a model that never passed its check, the way a new
install does; a checked model is never deleted that way.
## Stop, Start, Remove
- **Stop** stops the runtime and keeps it from starting at the next boot. **Start** undoes
that, and so does a switch to another model or the install of one: the job starts the
runtime, and the model it ends on is in use.
that.
- **Remove** stops the runtime, removes the endpoint record, switches the setting off again
only when this feature switched it on and no other endpoint is saved, and deletes the
runtime and its private home. The downloaded models (all of them) are deleted only when
asked. The coding agent stays. While a live session uses the model, Remove answers with
the session names instead.
- **Models a Remove kept** are not lost space: with nothing installed the window says how
much they still take and offers "Delete them", and after a new install they show in the
list as "Not checked", each with its own Delete.
runtime and its private home. The downloaded model is deleted only when asked. The coding
agent stays. While a live session uses the model, Remove answers with the session names
instead.
## Where everything is
All under the data directory (`~/.codeman`, or the instance's own):
```
local-model.json the state file (mode 0600), with the list of checked models
local-model.json the state file (mode 0600)
local-model/
runtime/ollama-0.40.15.tgz.partial the runtime archive while it downloads
runtime/ollama-0.40.15/ the unpacked runtime (about 579 MB)
@@ -374,12 +313,9 @@ in App Settings.
| ------------ | ------------------------------------------------------------- | ---------------- | -------------------------------------- |
| `idle` | stays idle | refused | install |
| `installing` | becomes `paused` at the same step | becomes `paused` | cancel |
| `paused` | stays paused | refused | resume, install, use, delete a model, remove |
| `failed` | stays failed, same error | refused | resume, install, use, delete a model, remove |
| `installed` | stays installed; the runtime is started unless it was stopped | refused | start a session, stop or start, install (add a model), use, delete a model, remove |
`use` and "delete a model" are listed only while a row of the list offers them, and in
`idle` "remove" is offered only while the model store still holds something.
| `paused` | stays paused | refused | resume, install, remove |
| `failed` | stays failed, same error | refused | resume, install, remove |
| `installed` | stays installed; the runtime is started unless it was stopped | refused | start a session, stop or start, remove |
One job at a time. A job a server restart interrupted comes back paused and never continues
without a click. Cancel interrupts the step in flight (the download, the pull, the request,
@@ -421,12 +357,6 @@ or `python3`.
on a Mac. "Recommended" appears only for the pair we ran, on the Mac we ran it on.
- **The model stays in memory for ten minutes** after the last request, then the runtime
unloads it.
- **One model runs at a time.** Several can be on disk; the runtime holds one, and a switch
restarts it and loads the other model (seconds to a minute). Close the sessions on the
model first: the window refuses the switch while one is open.
- **The disk figures are the runtime's.** A row's size is what the runtime reports for the
model, and the total is the size of its blob files. Neither was compared with Finder on a
Mac yet.
## Keeping the catalogue current
+1 -1
View File
@@ -30,7 +30,7 @@ Types live in `src/types/session.ts`; persistence in `src/remote-hosts.ts`.
| `RemoteHost` (extends `RemoteSshOptions`) | A saved host: `id`, `label`, `host`, `username`, `port?`, `commands?` (per-mode launch command override). |
| `RemoteCase` | A working directory on a host: `name`, `type: 'remote'`, `hostId`, `remotePath`. |
| `SessionRemote` (extends `RemoteSshOptions`) | The resolved bundle stamped onto a live session: host coordinates + `remotePath` + `commands`, plus **`owned?`** and **`remoteSessionName?`** (COD-105 — see [Ownership](#ownership-launched-vs-discovered-and-attached-cod-105)). Built by `toSessionRemote(host, case)` (sets `owned: true`) for the launch path, or `toAttachedSessionRemote(host, name, path)` (sets `owned: false`) for the attach path. Both copy the advanced SSH options through so every connection is identical. |
| `RemoteCommandMode` | `Extract<SessionMode, 'shell' \| 'claude' \| 'opencode' \| 'codex' \| 'gemini' \| 'antigravity' \| 'pi' \| 'grok' \| 'deepseek' \| 'omp'>` — the modes that can run remotely. |
| `RemoteCommandMode` | `Extract<SessionMode, 'shell' \| 'claude' \| 'opencode' \| 'codex' \| 'gemini' \| 'antigravity' \| 'pi' \| 'grok' \| 'deepseek' \| 'omp' \| 'jcode'>` — the modes that can run remotely. |
| `RemoteSessionInfo` (COD-105) | One discovered remote tmux session: `name` (always `codeman-*`), `attached` (a client is connected), `created` (epoch s), `windows`. Returned by `listRemoteCodemanSessions()`. |
Persistence is two flat JSON arrays in the instance data dir:
+37 -7
View File
@@ -1,9 +1,9 @@
# Agent CLIs
Codeman drives eleven run modes: ten agent CLIs plus a plain shell. This page covers picking
Codeman drives twelve run modes: eleven agent CLIs plus a plain shell. This page covers picking
one, setting it up, and the differences that actually change how you work.
## The eleven modes
## The twelve modes
| Mode | CLI | Get it |
| -------------------- | ---------------------------- | ---------------------------------------------------------------------- |
@@ -17,6 +17,7 @@ one, setting it up, and the differences that actually change how you work.
| **DeepSeek Harness** | `dsh` | [github.com/deepseek-ai/deepseek-harness](https://github.com/deepseek-ai/deepseek-harness) |
| **OMP** | `omp` | [github.com/can1357/oh-my-pi](https://github.com/can1357/oh-my-pi) |
| **GitHub Copilot** | `copilot` | [github.com/github/copilot-cli](https://github.com/github/copilot-cli) |
| **jcode** | `jcode` | [jcode.sh](https://jcode.sh) |
| **Terminal / Shell** | your `$SHELL` | Already installed. |
Any combination works, including all of them. The run mode is chosen per session from the
@@ -51,10 +52,11 @@ If a CLI is installed but a Run button for it never appears:
precisely to avoid this; a hand-written plist or unit will not.
3. Restart the server after installing a new CLI.
`pi`, `grok`, `omp`, `copilot` and `dsh` are additionally identity-probed rather than trusted by name:
`pi`, `grok`, `omp`, `copilot`, `dsh` and `jcode` are additionally identity-probed rather than trusted by name:
`pi` and `omp` are generic enough that something else on your PATH may answer to them,
`grok` has npm squatters, and Debian ships an unrelated `dsh` (dancer's shell). Each has a
status endpoint (`/api/grok/status`, `/api/deepseek/status`, `/api/omp/status`, `/api/copilot/status`) that reports
`grok` has npm squatters, an unrelated npm package is also called `jcode`, and Debian ships an
unrelated `dsh` (dancer's shell). Each has a status endpoint (`/api/grok/status`,
`/api/deepseek/status`, `/api/omp/status`, `/api/copilot/status`, `/api/jcode/status`) that reports
the path and version that actually resolved, so a misresolution is visible rather than
presenting as "the mode just does not work".
@@ -70,7 +72,7 @@ output. The other CLIs expose no equivalent.
| Respawn cycling and unattended runs | Yes | Yes |
| Cron jobs | Yes | Yes |
| Docker cases, remote SSH cases | Yes | Yes |
| Precise idle detection | Yes | Codex, Pi, OpenCode, OMP, Copilot, Gemini and Antigravity: same screen check, via their own prompt and working line. DeepSeek: reports its end of turn itself. Grok: generic cues only, unreliable |
| Precise idle detection | Yes | Codex, Pi, OpenCode, OMP, Copilot, Gemini, Antigravity and jcode: same screen check, via their own prompt and working line. DeepSeek: reports its end of turn itself. Grok: generic cues only, unreliable |
| Auto-resume when a usage limit resets | Yes | No |
| Plan usage chip | Yes | Codex (limits) and Copilot (monthly premium requests); the others no |
| Approvals Inbox | Yes | DeepSeek and Codex as alerts (answer in the terminal); others no |
@@ -352,6 +354,33 @@ Ralph tracker, as for the other non-Claude CLIs.
Guide: [`docs/copilot-integration.md`](https://github.com/Ark0N/Codeman/blob/master/docs/copilot-integration.md).
### jcode
A coding agent written in Rust, installed with `curl -fsSL https://jcode.sh/install | bash`
into `~/.local/bin`. jcode keeps its own logins and provider choice (`jcode login`, `/login`,
`~/.jcode/config.toml`); there is no Codeman-side login, key field, or bypass switch, and Run
sends no jcode config at all, because jcode never asks before running a tool. A session can
be started on a model (`--model`) or resume a jcode session by its id or short name
(`--resume`).
Four things worth knowing:
- **Run `jcode` once in a terminal first.** A fresh install opens an onboarding wizard
instead of the prompt for its first few launches, and a prompt typed into that wizard is
lost. Finish or skip it once (Esc), and Codeman sessions open straight at the prompt.
- **All jcode sessions share one background daemon**, which jcode starts the first time and
keeps running after the tab that started it is closed. jcode's tools run inside that
daemon, so they see the daemon's environment, not the tab's. For that reason Codeman does
not inject its agent skill into jcode, and accepts no per-session `JCODE_*` environment
variables: set those in `~/.jcode/config.toml` or in the server's own environment.
- **Read a jcode worker from its terminal.** Codeman cannot read jcode's answers back yet,
so the Response Viewer and `last-response` stay empty for it; `codeman agent read <id>
--tail 3000` (or the terminal tail) shows what it said.
- **Idle detection is the precise kind**: Codeman reads jcode's spinner row above the
prompt, and the wheel or a swipe pages jcode's own transcript.
Guide: [`docs/jcode-integration.md`](https://github.com/Ark0N/Codeman/blob/master/docs/jcode-integration.md).
### Terminal / Shell
A plain shell in a tmux session. No agent, no hooks, no idle detection.
@@ -379,6 +408,7 @@ respawns. Which variables are accepted depends on the mode:
| DeepSeek | `DSH_*`, `DEEPSEEK_*` |
| OMP | `OMP_*` |
| Copilot | `COPILOT_*` (a token goes in `COPILOT_GITHUB_TOKEN`; `GH_TOKEN` and `GITHUB_TOKEN` are not accepted as per-session overrides) |
| jcode | none (its tools run in a shared daemon, see [jcode](#jcode)) |
Anything outside the allowlist is rejected at the schema. This is intentional: the allowlist
is one global list, so widening it for one CLI widens it for all of them. In multi-user mode
@@ -394,7 +424,7 @@ into the case's `.claude/settings.local.json` so that `/model` keeps working.
- **Claude Code** if you want every Codeman feature. Unattended overnight runs, usage-limit
auto-resume, the Approvals Inbox, and subagent visualization all assume it.
- **Codex, OpenCode, Gemini, Antigravity, Grok, OMP, GitHub Copilot** when you prefer that agent or that
- **Codex, OpenCode, Gemini, Antigravity, Grok, OMP, GitHub Copilot, jcode** when you prefer that agent or that
model. You get the session layer, respawn, cron, Docker, and remote SSH; you do not get the
hook-driven features (Codex dialogs still raise the red alert and an alert-only inbox card,
see [Codex](#codex) above).
+1 -1
View File
@@ -122,7 +122,7 @@ Conventions for wiki pages:
- Images are referenced from the main repository over raw URLs rather than being copied into
the wiki.
- Say what the default is, especially when it is off. Most of Codeman is opt-in.
- Label Claude-only behaviour every time it appears. Ten of the eleven run modes are not
- Label Claude-only behaviour every time it appears. Eleven of the twelve run modes are not
Claude.
## Conduct
+16 -16
View File
@@ -92,10 +92,10 @@ A session carries state the case does not:
## Run mode
The **run mode** is which CLI the session runs: `claude`, `opencode`, `codex`, `gemini`,
`antigravity`, `pi`, `grok`, `deepseek`, `omp`, `copilot`, or `shell`. It is chosen at start and does not change afterwards; to
`antigravity`, `pi`, `grok`, `deepseek`, `omp`, `copilot`, `jcode`, or `shell`. It is chosen at start and does not change afterwards; to
switch, start another session.
Claude is the reference mode. Nine of the ten are not Claude, and a number of Codeman
Claude is the reference mode. Eleven of the twelve are not Claude, and a number of Codeman
features are Claude-only for structural reasons rather than missing effort: they depend on
Claude Code's hook system or on parsing its terminal output. Every such feature is labelled
Claude-only where it appears, and [Agent CLIs](Agent-CLIs) lists them in one place.
@@ -110,8 +110,8 @@ Where a case runs is **separate from** which CLI it runs. There are three locati
| **Docker** | One long-lived container per case; sessions `docker exec` into it. See [Docker Cases](Docker-Cases). |
| **Remote SSH** | A durable tmux server on the remote host, fronted by a local pane running `ssh`. See [Remote SSH Sessions](Remote-SSH-Sessions). |
This matters because it is a common source of confusion: Docker is **not** an eleventh run
mode. All eleven run modes work in all three locations. A case is docker-backed or
This matters because it is a common source of confusion: Docker is **not** a thirteenth run
mode. All twelve run modes work in all three locations. A case is docker-backed or
ssh-backed; a session is claude or codex or shell.
**Web tabs** are the other thing that is not a session. A saved dashboard URL renders as a
@@ -213,21 +213,21 @@ See [Hooks And Integrations](Hooks-And-Integrations).
## Vocabulary
| Term | Means |
| ---------------- | ------------------------------------------------------------------------------------------------- |
| **Case** | Named working directory. |
| **Session** | One CLI in one tmux session. |
| **Run mode** | Which CLI: claude, opencode, codex, gemini, antigravity, pi, grok, deepseek, omp, copilot, shell. |
| **Respawn** | Restarting the CLI on idle to keep an unattended run going. |
| **Ralph loop** | An autonomous single-session task loop. |
| **Orchestrator** | A phased plan driven across multiple agents. |
| **Subagent** | An agent the CLI spawned itself, shown live in its own window. |
| **Web tab** | A saved dashboard URL rendered as a tab. Not a session. |
| **Instance** | One Codeman server with its own data directory and tmux socket. |
| Term | Means |
| ---------------- | -------------------------------------------------------------------------------------------------------- |
| **Case** | Named working directory. |
| **Session** | One CLI in one tmux session. |
| **Run mode** | Which CLI: claude, opencode, codex, gemini, antigravity, pi, grok, deepseek, omp, copilot, jcode, shell. |
| **Respawn** | Restarting the CLI on idle to keep an unattended run going. |
| **Ralph loop** | An autonomous single-session task loop. |
| **Orchestrator** | A phased plan driven across multiple agents. |
| **Subagent** | An agent the CLI spawned itself, shown live in its own window. |
| **Web tab** | A saved dashboard URL rendered as a tab. Not a session. |
| **Instance** | One Codeman server with its own data directory and tmux socket. |
## Read next
- [The Dashboard](The-Dashboard) - what the UI is showing you.
- [Agent CLIs](Agent-CLIs) - the eleven run modes in detail.
- [Agent CLIs](Agent-CLIs) - the twelve run modes in detail.
- [Keeping Agents Running](Keeping-Agents-Running) - respawn, idle detection, usage limits.
- [`docs/architecture-invariants.md`](https://github.com/Ark0N/Codeman/blob/master/docs/architecture-invariants.md) - the mechanisms behind all of this, for contributors.
-5
View File
@@ -234,11 +234,6 @@ Good to know:
but it is changed and removed in the local model window, not here.
- Codeman starts the runtime with the server and stops it with it. **Stop** keeps it from
starting next time.
- More than one model can be on the Mac, and one of them runs at a time. Once a model is
installed the window lists the models with what each takes on disk: **Add a model**
downloads another one, **Use** switches to a model that is already there (nothing is
downloaded; close the sessions on the model first), and **Delete** removes one, after one
question. The model in use cannot be deleted: switch first, or use **Remove**.
- **Remove** deletes the runtime and the endpoint, and the model too if you tick that. The
coding agent stays installed.
- If the model on disk is no longer the one that was installed, Codeman refuses to start a
+5 -3
View File
@@ -4,7 +4,7 @@ Run a case inside its own container instead of directly on your host: for isolat
reproducible toolchain, and for the ability to pick the whole environment up and move it to
another machine.
A docker case is a **location overlay**, not a run mode. All eleven run modes work inside a
A docker case is a **location overlay**, not a run mode. All twelve run modes work inside a
container. See [Core Concepts](Core-Concepts).
## One-time setup: the base image
@@ -26,7 +26,7 @@ A zero exit code proves the layers ran, not that the toolchain works. Verify:
```bash
docker run --rm codeman/agent:base bash -lc \
'for c in claude codex gemini opencode agy pi grok dsh omp; do printf "%-9s " $c; $c --version 2>&1 | head -1; done'
'for c in claude codex gemini opencode agy pi grok dsh omp jcode; do printf "%-9s " $c; $c --version 2>&1 | head -1; done'
```
The image is secret-free. Credentials are delivered at runtime, never baked in, so exports
@@ -120,7 +120,9 @@ as whole directories, because those directories also hold sessions, extensions,
installed packages, which can be gigabytes. So in-container Pi and Grok sessions are
invisible from the host (`pi -c` and `grok -c` inside a docker case see only that
container's history). OMP's `sessions/` is the exception and is shared read-write, because
Codeman reads it host-side for history and resume.
Codeman reads it host-side for history and resume. jcode's credentials are not seeded at all
(`~/.jcode` keeps its OAuth tokens beside its builds, sessions and caches): sign in to jcode
inside the container.
GitHub Copilot CLI is not seeded: on a host without a keyring its sign-in token sits in plain
text in `~/.copilot/config.json`, and there is no Docker credential row for it. Sign in inside
+1 -1
View File
@@ -24,7 +24,7 @@ stores, or refreshes your credentials.
### Which agent CLIs does it support?
Claude Code, OpenCode, Codex, Gemini, Antigravity, Pi, Grok Build, DeepSeek Harness,
OMP and GitHub Copilot CLI, plus a plain shell, chosen per session. Claude is the reference mode and a few features
OMP, GitHub Copilot CLI and jcode, plus a plain shell, chosen per session. Claude is the reference mode and a few features
are Claude-only; [Agent CLIs](Agent-CLIs) has the table.
### Does Codeman send my code or prompts anywhere?
+3 -3
View File
@@ -6,7 +6,7 @@
Codeman runs your coding agents on your own machine and puts them behind one dashboard you
can open from any device. It spawns Claude Code, OpenCode, Codex, Antigravity, Gemini, Pi,
Grok, DeepSeek Harness, OMP, or GitHub Copilot CLI inside persistent tmux sessions, streams the real terminal to the browser, and keeps
Grok, DeepSeek Harness, OMP, GitHub Copilot CLI, or jcode inside persistent tmux sessions, streams the real terminal to the browser, and keeps
working while you are away from the keyboard: it re-prompts idle agents, resumes when a
subscription limit resets, runs jobs on a schedule, and shows every background subagent
live.
@@ -33,7 +33,7 @@ codeman web # then open http://localhost:3000
**Already running it**
- [Agent CLIs](Agent-CLIs) - the eleven run modes, their setup, and which features are Claude-only.
- [Agent CLIs](Agent-CLIs) - the twelve run modes, their setup, and which features are Claude-only.
- [Mobile Guide](Mobile-Guide) - phone and tablet use, QR login, the touch keyboard bar.
- [Remote Access](Remote-Access) - Tailscale, Cloudflare tunnel, LAN plus password, QR login.
- [Keeping Agents Running](Keeping-Agents-Running) - idle detection, respawn cycling, auto-resume on usage limits.
@@ -129,7 +129,7 @@ codeman web # then open http://localhost:3000
| OS | macOS or Linux. Windows works through WSL2. |
| Node.js | 22 or newer. |
| tmux | Required. Sessions live in tmux, which is what makes them survive restarts. |
| An agent CLI | At least one of Claude Code, OpenCode, Codex, Gemini, Antigravity, Pi, Grok Build, DeepSeek Harness, OMP, GitHub Copilot CLI. Plain shell sessions need none. |
| An agent CLI | At least one of Claude Code, OpenCode, Codex, Gemini, Antigravity, Pi, Grok Build, DeepSeek Harness, OMP, GitHub Copilot CLI, jcode. Plain shell sessions need none. |
| Network | Binds to `127.0.0.1` by default. Reaching it from another device is a deliberate step: see [Remote Access](Remote-Access). |
Codeman is MIT licensed, self-hosted, and sends no telemetry. Everything runs on your
+2 -1
View File
@@ -9,7 +9,7 @@ Getting Codeman onto a machine, verifying it works, updating it, and removing it
| **macOS or Linux** | Windows works through WSL2. See [Windows](#windows-wsl) below. |
| **Node.js 22+** | The installer offers to install it if missing. |
| **tmux** | Not optional. Sessions live inside tmux, which is what makes them survive a server restart, a dropped connection, or a closed laptop. |
| **An agent CLI** | At least one of [Claude Code](https://docs.anthropic.com/en/docs/claude-code), [OpenCode](https://opencode.ai), [Codex](https://developers.openai.com/codex/cli), [Antigravity](https://antigravity.google), [Gemini CLI](https://github.com/google-gemini/gemini-cli), [Pi](https://pi.dev), [Grok Build](https://github.com/xai-org/grok-build), [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness), [OMP](https://github.com/can1357/oh-my-pi). Plain shell sessions need none. See [Agent CLIs](Agent-CLIs). |
| **An agent CLI** | At least one of [Claude Code](https://docs.anthropic.com/en/docs/claude-code), [OpenCode](https://opencode.ai), [Codex](https://developers.openai.com/codex/cli), [Antigravity](https://antigravity.google), [Gemini CLI](https://github.com/google-gemini/gemini-cli), [Pi](https://pi.dev), [Grok Build](https://github.com/xai-org/grok-build), [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness), [OMP](https://github.com/can1357/oh-my-pi), [jcode](https://jcode.sh). Plain shell sessions need none. See [Agent CLIs](Agent-CLIs). |
Codeman itself sends no telemetry and phones no home. The only network traffic is your
browser to your server, and whatever the agent CLI you chose does on its own, plus the
@@ -309,6 +309,7 @@ Codeman drives CLIs, it does not bundle them. Install at least one:
| **Grok Build** | `curl -fsSL https://x.ai/cli/install.sh \| bash` | xAI. Lands in `~/.grok/bin`; `grok login --device-auth` for headless hosts. |
| **DeepSeek Harness** | `npm i -g @deepseek-ai/dsh pnpm`, then a terminal profile | The npm package is only a launcher. Codeman's Run menu installs the community terminal profile for you. See [Agent CLIs](Agent-CLIs). |
| **OMP** | `curl -fsSL https://omp.sh/install \| sh` | Oh My Pi. Run it once by hand to finish its own onboarding. |
| **jcode** | `curl -fsSL https://jcode.sh/install \| bash` | Run it once by hand to finish (or skip) its onboarding wizard. |
Log each CLI in once, by hand, before pointing Codeman at it. Codeman never collects or
stores your CLI credentials.
+1
View File
@@ -70,6 +70,7 @@ one:
| **Grok Build** | xAI's CLI. |
| **DeepSeek Harness** | Needs a terminal profile; the menu offers to install one. |
| **OMP** | Oh My Pi, configured entirely through its own `~/.omp`. |
| **jcode** | Configured through its own `~/.jcode`; no permission prompts. |
| **Terminal / Shell** | A plain shell, no agent. Also the **Run Shell** button. |
The dropdown also lists any saved dashboard URLs ([Web Tabs](Web-Tabs)) and your recent
+1 -1
View File
@@ -4,7 +4,7 @@ Point a case at another machine and the agent runs **there**, with the same dash
mobile UI, and autonomy features. Your laptop becomes a window onto a session living on the
remote host.
Like Docker, this is a **location overlay** on a case, not a run mode. All eleven run modes
Like Docker, this is a **location overlay** on a case, not a run mode. All twelve run modes
work remotely. See [Core Concepts](Core-Concepts).
## Why bother
+13 -13
View File
@@ -224,19 +224,19 @@ export PUPPETEER_SKIP_DOWNLOAD="${PUPPETEER_SKIP_DOWNLOAD:-1}"
# script will ever execute, and they arrive embedded in this file — same TLS fetch, same
# commit as the script itself. Nothing fetched at install time is ever executed; there is
# no network refresh of these arrays. See cli_catalog_select_platform below.
CLI_IDS=('claude' 'shell' 'opencode' 'codex' 'gemini' 'antigravity' 'pi' 'grok' 'deepseek' 'omp' 'copilot')
CLI_LABELS=('Claude Code' 'Shell' 'OpenCode' 'Codex' 'Gemini' 'Antigravity' 'Pi' 'Grok' 'DeepSeek' 'OMP' 'GitHub Copilot')
CLI_ENABLED=(1 1 1 1 1 1 1 1 1 1 1)
CLI_LAUNCHER_ONLY=(0 0 0 0 0 0 0 0 1 0 0)
CLI_DOCS=('https://docs.claude.com/claude-code' '' 'https://opencode.ai/docs' 'https://developers.openai.com/codex/cli' 'https://github.com/google-gemini/gemini-cli' 'https://antigravity.google/cli' 'https://pi.dev' 'https://github.com/xai-org/grok-build' 'https://github.com/deepseek-ai/deepseek-harness' 'https://omp.sh' 'https://github.com/github/copilot-cli')
CLI_CMD_LINUX=('curl -fsSL https://claude.ai/install.sh | bash' '' 'curl -fsSL https://opencode.ai/install | bash' 'npm install -g @openai/codex' 'npm install -g @google/gemini-cli' 'curl -fsSL https://antigravity.google/cli/install.sh | bash' 'npm install -g --ignore-scripts @earendil-works/pi-coding-agent' 'curl -fsSL https://x.ai/cli/install.sh | bash' '' 'curl -fsSL https://omp.sh/install | sh' 'npm install -g @github/copilot')
CLI_CMD_DARWIN=('curl -fsSL https://claude.ai/install.sh | bash' '' 'curl -fsSL https://opencode.ai/install | bash' 'npm install -g @openai/codex' 'npm install -g @google/gemini-cli' 'curl -fsSL https://antigravity.google/cli/install.sh | bash' 'npm install -g --ignore-scripts @earendil-works/pi-coding-agent' 'curl -fsSL https://x.ai/cli/install.sh | bash' '' 'brew install can1357/tap/omp' 'npm install -g @github/copilot')
CLI_ALL_BINS=('claude' 'opencode' 'codex' 'gemini' 'agy' 'pi' 'grok' 'dsh' 'omp' 'copilot')
CLI_BIN_OFF=(0 1 1 2 3 4 5 6 7 8 9)
CLI_BIN_LEN=(1 0 1 1 1 1 1 1 1 1 1)
CLI_ALL_PATHS=("$HOME/.local/bin/claude" "$HOME/.claude/local/claude" "/usr/local/bin/claude" "$HOME/.npm-global/bin/claude" "$HOME/bin/claude" "$HOME/.opencode/bin/opencode" "$HOME/.local/bin/opencode" "/usr/local/bin/opencode" "$HOME/go/bin/opencode" "$HOME/.bun/bin/opencode" "$HOME/.npm-global/bin/opencode" "$HOME/bin/opencode" "$HOME/.codex/bin/codex" "$HOME/.local/bin/codex" "/usr/local/bin/codex" "$HOME/.bun/bin/codex" "$HOME/.npm-global/bin/codex" "$HOME/bin/codex" "$HOME/.gemini/bin/gemini" "$HOME/.local/bin/gemini" "/usr/local/bin/gemini" "$HOME/.bun/bin/gemini" "$HOME/.npm-global/bin/gemini" "$HOME/bin/gemini" "$HOME/.local/bin/agy" "$HOME/.antigravity/bin/agy" "/usr/local/bin/agy" "$HOME/bin/agy" "$HOME/.local/bin/pi" "/usr/local/bin/pi" "$HOME/.bun/bin/pi" "$HOME/.npm-global/bin/pi" "$HOME/bin/pi" "$HOME/.grok/bin/grok" "$HOME/.local/bin/grok" "/usr/local/bin/grok" "$HOME/bin/grok" "$HOME/.local/bin/dsh" "/usr/local/bin/dsh" "$HOME/.npm-global/bin/dsh" "$HOME/bin/dsh" "$HOME/.local/bin/omp" "$HOME/.omp/bin/omp" "/usr/local/bin/omp" "$HOME/.bun/bin/omp" "$HOME/.npm-global/bin/omp" "$HOME/bin/omp" "$HOME/.local/bin/copilot" "/usr/local/bin/copilot" "$HOME/.bun/bin/copilot" "$HOME/.npm-global/bin/copilot" "$HOME/bin/copilot")
CLI_PATH_OFF=(0 5 5 12 18 24 28 33 37 41 47)
CLI_PATH_LEN=(5 0 7 6 6 4 5 4 4 6 5)
CLI_IDS=('claude' 'shell' 'opencode' 'codex' 'gemini' 'antigravity' 'pi' 'grok' 'deepseek' 'omp' 'copilot' 'jcode')
CLI_LABELS=('Claude Code' 'Shell' 'OpenCode' 'Codex' 'Gemini' 'Antigravity' 'Pi' 'Grok' 'DeepSeek' 'OMP' 'GitHub Copilot' 'jcode')
CLI_ENABLED=(1 1 1 1 1 1 1 1 1 1 1 1)
CLI_LAUNCHER_ONLY=(0 0 0 0 0 0 0 0 1 0 0 0)
CLI_DOCS=('https://docs.claude.com/claude-code' '' 'https://opencode.ai/docs' 'https://developers.openai.com/codex/cli' 'https://github.com/google-gemini/gemini-cli' 'https://antigravity.google/cli' 'https://pi.dev' 'https://github.com/xai-org/grok-build' 'https://github.com/deepseek-ai/deepseek-harness' 'https://omp.sh' 'https://github.com/github/copilot-cli' 'https://jcode.sh/docs')
CLI_CMD_LINUX=('curl -fsSL https://claude.ai/install.sh | bash' '' 'curl -fsSL https://opencode.ai/install | bash' 'npm install -g @openai/codex' 'npm install -g @google/gemini-cli' 'curl -fsSL https://antigravity.google/cli/install.sh | bash' 'npm install -g --ignore-scripts @earendil-works/pi-coding-agent' 'curl -fsSL https://x.ai/cli/install.sh | bash' '' 'curl -fsSL https://omp.sh/install | sh' 'npm install -g @github/copilot' 'curl -fsSL https://jcode.sh/install | bash')
CLI_CMD_DARWIN=('curl -fsSL https://claude.ai/install.sh | bash' '' 'curl -fsSL https://opencode.ai/install | bash' 'npm install -g @openai/codex' 'npm install -g @google/gemini-cli' 'curl -fsSL https://antigravity.google/cli/install.sh | bash' 'npm install -g --ignore-scripts @earendil-works/pi-coding-agent' 'curl -fsSL https://x.ai/cli/install.sh | bash' '' 'brew install can1357/tap/omp' 'npm install -g @github/copilot' 'curl -fsSL https://jcode.sh/install | bash')
CLI_ALL_BINS=('claude' 'opencode' 'codex' 'gemini' 'agy' 'pi' 'grok' 'dsh' 'omp' 'copilot' 'jcode')
CLI_BIN_OFF=(0 1 1 2 3 4 5 6 7 8 9 10)
CLI_BIN_LEN=(1 0 1 1 1 1 1 1 1 1 1 1)
CLI_ALL_PATHS=("$HOME/.local/bin/claude" "$HOME/.claude/local/claude" "/usr/local/bin/claude" "$HOME/.npm-global/bin/claude" "$HOME/bin/claude" "$HOME/.opencode/bin/opencode" "$HOME/.local/bin/opencode" "/usr/local/bin/opencode" "$HOME/go/bin/opencode" "$HOME/.bun/bin/opencode" "$HOME/.npm-global/bin/opencode" "$HOME/bin/opencode" "$HOME/.codex/bin/codex" "$HOME/.local/bin/codex" "/usr/local/bin/codex" "$HOME/.bun/bin/codex" "$HOME/.npm-global/bin/codex" "$HOME/bin/codex" "$HOME/.gemini/bin/gemini" "$HOME/.local/bin/gemini" "/usr/local/bin/gemini" "$HOME/.bun/bin/gemini" "$HOME/.npm-global/bin/gemini" "$HOME/bin/gemini" "$HOME/.local/bin/agy" "$HOME/.antigravity/bin/agy" "/usr/local/bin/agy" "$HOME/bin/agy" "$HOME/.local/bin/pi" "/usr/local/bin/pi" "$HOME/.bun/bin/pi" "$HOME/.npm-global/bin/pi" "$HOME/bin/pi" "$HOME/.grok/bin/grok" "$HOME/.local/bin/grok" "/usr/local/bin/grok" "$HOME/bin/grok" "$HOME/.local/bin/dsh" "/usr/local/bin/dsh" "$HOME/.npm-global/bin/dsh" "$HOME/bin/dsh" "$HOME/.local/bin/omp" "$HOME/.omp/bin/omp" "/usr/local/bin/omp" "$HOME/.bun/bin/omp" "$HOME/.npm-global/bin/omp" "$HOME/bin/omp" "$HOME/.local/bin/copilot" "/usr/local/bin/copilot" "$HOME/.bun/bin/copilot" "$HOME/.npm-global/bin/copilot" "$HOME/bin/copilot" "$HOME/.local/bin/jcode" "/usr/local/bin/jcode" "$HOME/bin/jcode")
CLI_PATH_OFF=(0 5 5 12 18 24 28 33 37 41 47 52)
CLI_PATH_LEN=(5 0 7 6 6 4 5 4 4 6 5 3)
# <<< END GENERATED CLI CATALOGUE
# ============================================================================
+1 -1
View File
@@ -826,7 +826,7 @@ Harness TUI reports `idle`/`working`/`blocked` to Codeman over the supervisor co
implements, so dsh is the one external CLI with definitive `stop`/`blocked` signals
instead of guessed-from-silence ones — and it writes a structured transcript, which is
what `last-response` reads for it. `shell`, `opencode`, `gemini`, `antigravity`, `pi`,
`grok`, `omp` and `copilot` have neither, and `codex` has a transcript and a screen-read `blocked` but
`grok`, `omp`, `copilot` and `jcode` have neither, and `codex` has a transcript and a screen-read `blocked` but
no `stop`, so all of them still need markers ([§5.5](reference/verbs.md#55-markers-for-hook-less-workers)).
Three things to know before you spawn one:
@@ -242,7 +242,7 @@ minutes, never retry the credential.
flushed slightly *after* the `stop` hook fires, so a read taken the instant the wait
returns is too early (verified live: empty on the first call, full prose seconds later).
It is also `""` before the worker's first completed turn, and permanently `""` for
`shell`, `opencode`, `gemini`, `antigravity`, `pi`, `grok`, `omp` and `copilot`, which write no transcript at
`shell`, `opencode`, `gemini`, `antigravity`, `pi`, `grok`, `omp`, `copilot` and `jcode`, which write no transcript at
all. `deepseek` is NOT one of those — it is read from `$DSH_HOME/sessions/**` and lags
for the same reason claude does (the harness finalizes the assistant message just after
it reports `idle`), so poll it the same way.
@@ -346,7 +346,7 @@ ESC=$(printf '\033')
`POST /api/v1/quick-start` body (all optional):
`{"caseName":"worker-1","mode":"claude","sessionName":"auth-worker","effort":"high"}`
, `mode` ∈ `claude|shell|opencode|codex|gemini|antigravity|pi|grok|deepseek|omp|copilot`; response is
, `mode` ∈ `claude|shell|opencode|codex|gemini|antigravity|pi|grok|deepseek|omp|copilot|jcode`; response is
`.data.{sessionId, caseName, casePath}`. Creates the case directory (a real directory
on the user's disk) if missing, do not retry it in a loop, and remember the name.
@@ -361,9 +361,10 @@ a soft default the worker can change with `/advisor`. Remote and docker cases re
`OPERATION_FAILED`; it never falls back to claude. Probe first whenever you did not pick
the mode yourself: `GET /api/v1/claude/status`, `GET /api/v1/opencode/status`,
`GET /api/v1/codex/status`, `GET /api/v1/gemini/status`, `GET /api/v1/antigravity/status`, `GET /api/v1/grok/status`, `GET /api/v1/deepseek/status`,
`GET /api/v1/pi/status`, `GET /api/v1/omp/status` and `GET /api/v1/copilot/status` each return `.data.{available, path}` (no session needed).
Pi's, grok's, OMP's and Copilot's also carry `.data.version`, because `pi` is a short generic name,
`grok` is a name with npm squatters, and `omp` is a similarly short name, and `copilot` is a generic one, so an unrelated
`GET /api/v1/pi/status`, `GET /api/v1/omp/status`, `GET /api/v1/copilot/status` and `GET /api/v1/jcode/status` each return `.data.{available, path}` (no session needed).
Pi's, grok's, OMP's, Copilot's and jcode's also carry `.data.version`, because `pi` is a short generic name,
`grok` is a name with npm squatters, `omp` is a similarly short name, `copilot` is a generic one, and an
unrelated npm package is also called `jcode`, so an unrelated
binary on `$PATH` can shadow any of them: the resolver rejects one whose `--version` is
not version-shaped, so `available:false` there can mean "a different program of the same
name is in front" rather than "nothing is installed". `shell` has no CLI to probe.
@@ -492,8 +493,8 @@ Quirks that will bite you:
- ⚠️ **`active-tools` proves presence, never absence.** It is fed by the BashToolParser,
which reads Claude's rendered `● Bash(…)` lines, and `_processExpensiveParsers`
returns early for every external CLI mode (`session.ts:~2225`), so it is permanently
`[]` on `opencode`/`codex`/`gemini`/`antigravity`/`pi`/`grok`/`deepseek`/`omp`/`copilot`. ⚠️ **`shell` is NOT one of those**
(`isExternalCliMode`, `session.ts:176-187`, lists only those seven), so the parser does
`[]` on `opencode`/`codex`/`gemini`/`antigravity`/`pi`/`grok`/`deepseek`/`omp`/`copilot`/`jcode`. ⚠️ **`shell` is NOT one of those**
(`isExternalCliMode` covers only those external CLIs), so the parser does
run on a shell worker, and `TEXT_COMMAND_PATTERN` (`bash-tool-parser.ts:89`) matches
bare `tail|cat|head|less|grep|watch|multitail <path>` lines with no `● Bash(` wrapper:
a shell worker running `cat build.log` really does populate this. In practice it stays
@@ -56,7 +56,7 @@ own head: the worker enforcing the cap is the one who has to be told about it.
| synchronize on end of turn | HTTP `wait until=stop` (fires for message-initiated turns too, verified live) |
| liveness / death check | HTTP `wait?until=exit` |
| interrupt a running turn (break-glass) | HTTP input, a bare `\x1b` with no `\r` |
| non-claude modes (`shell`/`opencode`/`codex`/`gemini`/`antigravity`/`pi`/`grok`/`deepseek`/`omp`/`copilot`) | HTTP only (no other CLI has messaging) |
| non-claude modes (`shell`/`opencode`/`codex`/`gemini`/`antigravity`/`pi`/`grok`/`deepseek`/`omp`/`copilot`/`jcode`) | HTTP only (no other CLI has messaging) |
| delete | HTTP, via SKILL.md's `delete_session` guard |
## Availability: probe, never assume
@@ -352,7 +352,7 @@ Without a break-glass, a pair with a bad brief is a token bonfire with no off sw
### Mixed fleets: the pairing matrix
Non-claude workers (`shell`, `opencode`, `codex`, `gemini`, `antigravity`, `pi`, `grok`, `deepseek`, `omp`, `copilot`) cannot be peers
Non-claude workers (`shell`, `opencode`, `codex`, `gemini`, `antigravity`, `pi`, `grok`, `deepseek`, `omp`, `copilot`, `jcode`) cannot be peers
at all; no other CLI has this feature. Their tasks route over HTTP, and you never mention
messaging in their briefs. The claude half of the fleet can use messaging among itself,
subject to the namespace rule: **messaging works between two sessions that share one
@@ -188,7 +188,7 @@ for _ in $(seq 1 10); do
done
printf '%s\n' "$TXT"
# (.data is {text,timestamp}; text is also "" before the first completed turn and
# always "" for shell/opencode/gemini/antigravity/pi/grok/omp/copilot, which have no transcript, use
# always "" for shell/opencode/gemini/antigravity/pi/grok/omp/copilot/jcode, which have no transcript, use
# the terminal tail there, and here only to diagnose an unsubmitted prompt.)
# 6. clean up: exact id, own list only, through the fail-closed preamble helper
@@ -383,7 +383,7 @@ recovered by submitting it with `{"input":"\r"}`.
only when the workspace actually has them, see [§5.1](#51-where-to-spawn)) **and for
`deepseek`** — the one external CLI that reports its own lifecycle, so its `stop` is a
real end-of-turn signal rather than a guess. On
`shell`/`opencode`/`codex`/`gemini`/`antigravity`/`pi`/`grok`/`omp`/`copilot`, requesting them explicitly is a
`shell`/`opencode`/`codex`/`gemini`/`antigravity`/`pi`/`grok`/`omp`/`copilot`/`jcode`, requesting them explicitly is a
400 (except `blocked` on `codex`, which Codeman reads off its screen when an approval,
question or trust dialog is up; a wait for it answers at once if one already is), and
lifecycle transitions there are coarse (a short shell command may emit **no**
@@ -437,7 +437,7 @@ from the transcript file, which is flushed slightly *after* the `stop` hook fire
single read taken the instant send-and-wait returns comes back `""` even though the
turn finished (verified live: empty on the first call, full text seconds later). `text`
is also `""` before the worker's first completed turn, and always `""` for modes with
no transcript (`shell`, `opencode`, `gemini`, `antigravity`, `pi`, `grok`, `omp`, `copilot`; the first four
no transcript (`shell`, `opencode`, `gemini`, `antigravity`, `pi`, `grok`, `omp`, `copilot`, `jcode`; the first four
verified live, pi from the same source path), which is
why the loop above is bounded rather than open-ended. A dsh worker lags too, for its own
reason: the harness finalizes the assistant message just after it reports `idle`. Fall back to the terminal buffer
@@ -526,7 +526,7 @@ turn), and both better than diffing terminal samples:
```
⚠️ `active-tools` is parsed out of Claude's own output format, so it is **empty for
`opencode`/`codex`/`gemini`/`antigravity`/`pi`/`grok`/`deepseek`/`omp`/`copilot`** (those parsers are skipped wholesale) and
`opencode`/`codex`/`gemini`/`antigravity`/`pi`/`grok`/`deepseek`/`omp`/`copilot`/`jcode`** (those parsers are skipped wholesale) and
in practice empty for `shell`. Source-verified, not measured live.
Only if neither helps: sample `terminal?tail=` twice a few seconds apart. A changing
+1 -1
View File
@@ -826,7 +826,7 @@ Harness TUI reports `idle`/`working`/`blocked` to Codeman over the supervisor co
implements, so dsh is the one external CLI with definitive `stop`/`blocked` signals
instead of guessed-from-silence ones — and it writes a structured transcript, which is
what `last-response` reads for it. `shell`, `opencode`, `gemini`, `antigravity`, `pi`,
`grok`, `omp` and `copilot` have neither, and `codex` has a transcript and a screen-read `blocked` but
`grok`, `omp`, `copilot` and `jcode` have neither, and `codex` has a transcript and a screen-read `blocked` but
no `stop`, so all of them still need markers ([§5.5](reference/verbs.md#55-markers-for-hook-less-workers)).
Three things to know before you spawn one:
+8 -7
View File
@@ -242,7 +242,7 @@ minutes, never retry the credential.
flushed slightly *after* the `stop` hook fires, so a read taken the instant the wait
returns is too early (verified live: empty on the first call, full prose seconds later).
It is also `""` before the worker's first completed turn, and permanently `""` for
`shell`, `opencode`, `gemini`, `antigravity`, `pi`, `grok`, `omp` and `copilot`, which write no transcript at
`shell`, `opencode`, `gemini`, `antigravity`, `pi`, `grok`, `omp`, `copilot` and `jcode`, which write no transcript at
all. `deepseek` is NOT one of those — it is read from `$DSH_HOME/sessions/**` and lags
for the same reason claude does (the harness finalizes the assistant message just after
it reports `idle`), so poll it the same way.
@@ -346,7 +346,7 @@ ESC=$(printf '\033')
`POST /api/v1/quick-start` body (all optional):
`{"caseName":"worker-1","mode":"claude","sessionName":"auth-worker","effort":"high"}`
, `mode` ∈ `claude|shell|opencode|codex|gemini|antigravity|pi|grok|deepseek|omp|copilot`; response is
, `mode` ∈ `claude|shell|opencode|codex|gemini|antigravity|pi|grok|deepseek|omp|copilot|jcode`; response is
`.data.{sessionId, caseName, casePath}`. Creates the case directory (a real directory
on the user's disk) if missing, do not retry it in a loop, and remember the name.
@@ -361,9 +361,10 @@ a soft default the worker can change with `/advisor`. Remote and docker cases re
`OPERATION_FAILED`; it never falls back to claude. Probe first whenever you did not pick
the mode yourself: `GET /api/v1/claude/status`, `GET /api/v1/opencode/status`,
`GET /api/v1/codex/status`, `GET /api/v1/gemini/status`, `GET /api/v1/antigravity/status`, `GET /api/v1/grok/status`, `GET /api/v1/deepseek/status`,
`GET /api/v1/pi/status`, `GET /api/v1/omp/status` and `GET /api/v1/copilot/status` each return `.data.{available, path}` (no session needed).
Pi's, grok's, OMP's and Copilot's also carry `.data.version`, because `pi` is a short generic name,
`grok` is a name with npm squatters, and `omp` is a similarly short name, and `copilot` is a generic one, so an unrelated
`GET /api/v1/pi/status`, `GET /api/v1/omp/status`, `GET /api/v1/copilot/status` and `GET /api/v1/jcode/status` each return `.data.{available, path}` (no session needed).
Pi's, grok's, OMP's, Copilot's and jcode's also carry `.data.version`, because `pi` is a short generic name,
`grok` is a name with npm squatters, `omp` is a similarly short name, `copilot` is a generic one, and an
unrelated npm package is also called `jcode`, so an unrelated
binary on `$PATH` can shadow any of them: the resolver rejects one whose `--version` is
not version-shaped, so `available:false` there can mean "a different program of the same
name is in front" rather than "nothing is installed". `shell` has no CLI to probe.
@@ -492,8 +493,8 @@ Quirks that will bite you:
- ⚠️ **`active-tools` proves presence, never absence.** It is fed by the BashToolParser,
which reads Claude's rendered `● Bash(…)` lines, and `_processExpensiveParsers`
returns early for every external CLI mode (`session.ts:~2225`), so it is permanently
`[]` on `opencode`/`codex`/`gemini`/`antigravity`/`pi`/`grok`/`deepseek`/`omp`/`copilot`. ⚠️ **`shell` is NOT one of those**
(`isExternalCliMode`, `session.ts:176-187`, lists only those seven), so the parser does
`[]` on `opencode`/`codex`/`gemini`/`antigravity`/`pi`/`grok`/`deepseek`/`omp`/`copilot`/`jcode`. ⚠️ **`shell` is NOT one of those**
(`isExternalCliMode` covers only those external CLIs), so the parser does
run on a shell worker, and `TEXT_COMMAND_PATTERN` (`bash-tool-parser.ts:89`) matches
bare `tail|cat|head|less|grep|watch|multitail <path>` lines with no `● Bash(` wrapper:
a shell worker running `cat build.log` really does populate this. In practice it stays
+2 -2
View File
@@ -56,7 +56,7 @@ own head: the worker enforcing the cap is the one who has to be told about it.
| synchronize on end of turn | HTTP `wait until=stop` (fires for message-initiated turns too, verified live) |
| liveness / death check | HTTP `wait?until=exit` |
| interrupt a running turn (break-glass) | HTTP input, a bare `\x1b` with no `\r` |
| non-claude modes (`shell`/`opencode`/`codex`/`gemini`/`antigravity`/`pi`/`grok`/`deepseek`/`omp`/`copilot`) | HTTP only (no other CLI has messaging) |
| non-claude modes (`shell`/`opencode`/`codex`/`gemini`/`antigravity`/`pi`/`grok`/`deepseek`/`omp`/`copilot`/`jcode`) | HTTP only (no other CLI has messaging) |
| delete | HTTP, via SKILL.md's `delete_session` guard |
## Availability: probe, never assume
@@ -352,7 +352,7 @@ Without a break-glass, a pair with a bad brief is a token bonfire with no off sw
### Mixed fleets: the pairing matrix
Non-claude workers (`shell`, `opencode`, `codex`, `gemini`, `antigravity`, `pi`, `grok`, `deepseek`, `omp`, `copilot`) cannot be peers
Non-claude workers (`shell`, `opencode`, `codex`, `gemini`, `antigravity`, `pi`, `grok`, `deepseek`, `omp`, `copilot`, `jcode`) cannot be peers
at all; no other CLI has this feature. Their tasks route over HTTP, and you never mention
messaging in their briefs. The claude half of the fleet can use messaging among itself,
subject to the namespace rule: **messaging works between two sessions that share one
+1 -1
View File
@@ -188,7 +188,7 @@ for _ in $(seq 1 10); do
done
printf '%s\n' "$TXT"
# (.data is {text,timestamp}; text is also "" before the first completed turn and
# always "" for shell/opencode/gemini/antigravity/pi/grok/omp/copilot, which have no transcript, use
# always "" for shell/opencode/gemini/antigravity/pi/grok/omp/copilot/jcode, which have no transcript, use
# the terminal tail there, and here only to diagnose an unsubmitted prompt.)
# 6. clean up: exact id, own list only, through the fail-closed preamble helper
+3 -3
View File
@@ -383,7 +383,7 @@ recovered by submitting it with `{"input":"\r"}`.
only when the workspace actually has them, see [§5.1](#51-where-to-spawn)) **and for
`deepseek`** — the one external CLI that reports its own lifecycle, so its `stop` is a
real end-of-turn signal rather than a guess. On
`shell`/`opencode`/`codex`/`gemini`/`antigravity`/`pi`/`grok`/`omp`/`copilot`, requesting them explicitly is a
`shell`/`opencode`/`codex`/`gemini`/`antigravity`/`pi`/`grok`/`omp`/`copilot`/`jcode`, requesting them explicitly is a
400 (except `blocked` on `codex`, which Codeman reads off its screen when an approval,
question or trust dialog is up; a wait for it answers at once if one already is), and
lifecycle transitions there are coarse (a short shell command may emit **no**
@@ -437,7 +437,7 @@ from the transcript file, which is flushed slightly *after* the `stop` hook fire
single read taken the instant send-and-wait returns comes back `""` even though the
turn finished (verified live: empty on the first call, full text seconds later). `text`
is also `""` before the worker's first completed turn, and always `""` for modes with
no transcript (`shell`, `opencode`, `gemini`, `antigravity`, `pi`, `grok`, `omp`, `copilot`; the first four
no transcript (`shell`, `opencode`, `gemini`, `antigravity`, `pi`, `grok`, `omp`, `copilot`, `jcode`; the first four
verified live, pi from the same source path), which is
why the loop above is bounded rather than open-ended. A dsh worker lags too, for its own
reason: the harness finalizes the assistant message just after it reports `idle`. Fall back to the terminal buffer
@@ -526,7 +526,7 @@ turn), and both better than diffing terminal samples:
```
⚠️ `active-tools` is parsed out of Claude's own output format, so it is **empty for
`opencode`/`codex`/`gemini`/`antigravity`/`pi`/`grok`/`deepseek`/`omp`/`copilot`** (those parsers are skipped wholesale) and
`opencode`/`codex`/`gemini`/`antigravity`/`pi`/`grok`/`deepseek`/`omp`/`copilot`/`jcode`** (those parsers are skipped wholesale) and
in practice empty for `shell`. Source-verified, not measured live.
Only if neither helps: sample `terminal?tail=` twice a few seconds apart. A changing
+2
View File
@@ -314,6 +314,8 @@ const capabilitiesSchema = z
// Keep the PTY's rows while a phone keyboard is up (inline renderers only).
// Optional: absent means the keyboard resizes, the safe default.
keyboardHold: z.literal('inline').optional(),
// Spare the CLI's own detached daemon (another process group) when a pane is killed.
sharedDaemon: z.boolean().optional(),
keyboardAccessory: z.enum(['agent', 'shell']),
privilegedCommandGate: z.boolean(),
startMode: z.enum(['interactive', 'shell']),
+137
View File
@@ -2023,6 +2023,142 @@ const COPILOT: CliEntry = {
},
};
// jcode (1jehuang/jcode, a Rust coding-agent TUI). Measured on a live v0.98.1 pane inside tmux
// (capture-pane every 200 ms through real turns, a recorded tmux client stream, PageUp/PageDown
// and SGR wheel reports, a bracketed-paste prompt; 2026-10-16). Three things about it shape
// this entry and are worth knowing before changing it:
//
// 1. Every jcode TUI is a CLIENT of one shared per-user daemon (`jcode serve`, started by the
// first client in its own session, so it outlives the pane that started it). The agent and
// its tools run in the DAEMON, with the daemon's environment: a second client's env never
// reaches its bash tool (measured, `probe=[]`). A session's `CODEMAN_SESSION_ID` and
// `CODEMAN_API_URL` are therefore whatever the session that started the daemon had, or
// nothing. That is why the agent skill is not injected (below) and why `JCODE_*` is not an
// `envOverrides` prefix: most of that namespace is read by the daemon, so a per-session
// value would do nothing, and where it did act (the session that starts the daemon) it
// would reach every jcode session, hook and command-path variables included.
// 2. It has no approval prompts and no bypass flag: bash runs ungated apart from jcode's own
// model-side reflection gate and a catastrophic-path deny. Nothing to clamp, nothing to
// read as blocked, same shape as pi.
// 3. A fresh install opens an onboarding wizard instead of the composer until its launch
// counter passes 5 (or 10 sessions exist). A prompt typed into it is lost, so a new user
// should run `jcode` once in a terminal (or press Esc in the pane) before driving it.
const JCODE: CliEntry = {
id: 'jcode' as CliEntry['id'],
label: 'jcode',
shortBadge: 'JC',
// No brand colour to borrow (the mark is a monochrome halftone donut, and its TUI's own blue
// is gemini's #8ab4f8), so an amber no other entry uses.
accent: '#f59e0b',
enabled: true,
stock: true,
order: 110,
kind: 'agent',
discovery: {
binaries: ['jcode'],
// The official installer links `~/.local/bin/jcode` (its `JCODE_INSTALL_DIR` default) to a
// launcher script under `~/.jcode/builds/`; the agent image puts it in `/usr/local/bin`.
searchDirs: [HOME_DIRS.local, HOME_DIRS.usrLocal, HOME_DIRS.homeBin],
// `jcode --version` prints `jcode v0.98.1 (1c7426eb1)`. npm carries an unrelated `jcode`
// package, so the probe is anchored on the `jcode v` prefix and a mismatch counts as absent.
version: { arg: '--version', regex: '(?:^|\\s)jcode v(\\d+\\.\\d+\\.\\d+)', requireVersionMatch: true },
install: {
command: {
linux: 'curl -fsSL https://jcode.sh/install | bash',
darwin: 'curl -fsSL https://jcode.sh/install | bash',
},
docsUrl: 'https://jcode.sh/docs',
agentImageLayer: {
kind: 'dedicated',
reason:
'Not on npm: jcode ships a standalone Rust binary through jcode.sh/install, whose launcher is a script that execs the binary beside it.',
},
},
},
launch: {
params: {
model: { type: 'token', pattern: 'model' },
// jcode ids look like `session_fox_1792160401272_f5b47ce47d631006` and `--resume` also
// takes the short name (`fox`). Its value is OPTIONAL (bare `--resume` opens the session
// picker), so the token must not be able to start with `-`, or it would be read as a
// flag of its own: `path-segment` requires a leading alphanumeric.
resumeId: { type: 'token', pattern: 'path-segment' },
},
variants: [
{
id: 'default',
args: [
{ lit: 'jcode' },
{ flag: '--model', valueFrom: 'model', when: { param: 'model', state: 'set' } },
{ flag: '--resume', valueFrom: 'resumeId', when: { param: 'resumeId', state: 'set' } },
],
},
],
legacyConfigAliases: { resumeId: 'resumeSessionId' },
legacyConfigField: 'jcodeConfig',
resumeAppend: { style: 'flag', flag: '--resume' },
},
env: {
exports: [{ name: 'COLORTERM', value: 'truecolor' }],
unset: ['NO_COLOR'],
// Provider logins live in jcode's own files (`~/.jcode/auth.json`, `~/.config/jcode/*.env`)
// and are read by the daemon, so there is nothing for the server to forward into the pane.
tmuxSetenvKeys: [],
dockerExecEnvNames: [],
// Deliberately none: see note 1 above the entry.
allowedPrefixes: [],
allowedKeys: [],
},
capabilities: {
...agentDefaults(),
// A full-screen alternate-screen TUI that turns mouse tracking on itself (tmux
// `alternate_on=1`, `mouse_any_flag=1`, `mouse_sgr_flag=1`), the opencode shape: kept, a
// drag would be reported to jcode instead of selecting text, so Auto Copy and copy-on-select
// would silently do nothing.
altScreen: 'strip-mux-and-mouse',
// Pages its own transcript on PageUp/PageDown (measured: PageUp brought the welcome header
// back, PageDown returned to the composer). Over the hollow alt-screen buffer the wheel and a
// swipe become those keys instead of doing nothing.
transcriptPageKeys: true,
// Closing a jcode tab must not take the shared daemon (note 1) down with it: it is a
// descendant of whichever pane started it, in a process group of its own (setsid).
// Measured before this flag: deleting that tab left the next tab's client looping on
// `Connection lost - retrying` with its prompt queued.
sharedDaemon: true,
echo: { policy: 'buffer', anchor: { kind: 'cursor' } },
// At rest the composer row is `<n>> ` (the prompt number, then `>`); while a turn runs the
// same row reads `<n>…` and a status row appears directly above it, a braille spinner (the
// gemini/omp frame set, ~12 fps) then the phase and the elapsed time:
// `⠋ sending… 0s`, `⠦ thinking… 2s · https`, `⠼ waiting for response… 3s · https`,
// `⠸ running bash · <detail> · 12s · Alt+B bg`, or, while streaming, the time first
// (`⠼ 12s · 45.1 tps · ↑16k ↓20`). Past a minute the time reads `1m 3s`, past an hour
// `1h 2m`. A dropped connection swaps the spinner for `↻ network disconnected, waiting to
// retry · … · 5s`, still mid-turn. At rest that row is empty or a dim tip, never a spinner.
// The spinner repaints the pane at least every 100 ms for the whole turn (recorded tmux
// client stream), so the pane is never quiet mid-turn, and at rest it sends nothing at all
// (12 s recorded: zero bytes), so the idle check settles.
// The glyph is the bare `>`: the turn-ending repaint redraws only the cells that changed,
// which on that row is the `…` turning into `>` (recorded: `\x1b[43;2H...> `), never the
// prompt number. It only ARMS the idle check. It also leaves the submit verifier inert,
// which is what jcode wants (no early-Enter defect): every composer row starts with its
// number, so no screen row starts with `>` the way the verifier requires.
workDetect: {
promptGlyph: '>',
workingLine: String.raw`(?:^|\n) *(?:[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏] [^\n]*\d[smh]\b|↻ network disconnected)`,
},
// jcode reads `./.agents/skills` (and `./.jcode/skills`, `./.claude/skills`), but its tools
// run in the shared daemon, whose CODEMAN_* identity belongs to whichever session started
// it (note 1). An injected skill would act as THAT session, so it is not injected.
agentSkillInjection: false,
customModelInjection: { kind: 'unsupported' },
},
overlays: {
// No credStore: Docker seeding is CRED_STORES in docker-hosts.ts, which has no `.jcode` row.
// `~/.jcode` holds the OAuth tokens beside 160 MB of builds, sessions and SQLite caches, and
// API keys live in `~/.config/jcode`; sign in inside the container instead.
},
};
/** The full stock catalog, in the order the run menu shows by default. */
export const STOCK_CLIS: CliEntry[] = [
CLAUDE,
@@ -2036,4 +2172,5 @@ export const STOCK_CLIS: CliEntry[] = [
DEEPSEEK,
OMP,
COPILOT,
JCODE,
];
+13
View File
@@ -622,6 +622,19 @@ export interface CliCapabilities {
* losing lines, so it resizes as before), and for remote and docker sessions.
*/
keyboardHold?: 'inline';
/**
* The CLI's TUI is a CLIENT of a per-user daemon that its first client starts and every
* later session of the CLI shares (jcode). The daemon detaches into its own session and
* process group, but stays a descendant of that first pane by parent pid, so a session
* kill signals only the descendants still in the pane's process group
* (`descendantsInPaneGroup()`, proc-tree.ts). Without it, closing the tab that happened
* to start the daemon took it down and cut every other session of the CLI off.
*
* The flip side is that processes the agent started through that daemon (its tools run
* there, not in the pane) outlive the tab; stopping them is the CLI's own business
* (`jcode server stop`). Absent = every descendant is killed, as for every other CLI.
*/
sharedDaemon?: boolean;
keyboardAccessory: 'agent' | 'shell';
/** Multi-user: this CLI is a raw shell, so its commands need the privileged gate. */
privilegedCommandGate: boolean;
File diff suppressed because it is too large. Load diff
-39
View File
@@ -808,45 +808,6 @@ export async function removePartialModelBlobs(paths?: LocalModelPaths): Promise<
return freed;
}
/** How many files of the blob folder are counted at most: a store holds a few dozen, and a walk must end. */
const STORE_MEASURE_MAX_FILES = 20_000;
/**
* What the model store takes on disk: the sizes of the regular files directly in
* `<models>/blobs`, summed. That folder is flat (one file per layer, plus the partial files of
* a download), so this is the store's real disk use, shared layers counted once, an
* interrupted download included. The manifests beside it are a few kilobytes and are left out.
*
* One `readdir` and one `lstat` per file, at most `STORE_MEASURE_MAX_FILES` of them. Nothing is
* followed: a `root`, a model store or a blob folder that is a symbolic link counts as empty,
* and so does a link in the place of a file. Never outside `LocalModelPaths.root`. Resolves 0
* when there is no store. Never throws.
*/
export async function measureModelStore(paths?: LocalModelPaths): Promise<number> {
let total = 0;
try {
const resolved = paths ?? localModelPaths(OLLAMA_RUNTIME_PIN.version);
const blobs = join(resolved.modelsDir, 'blobs');
if (!isInsideLocalModelRoot(blobs, resolved.root)) return 0;
for (const folder of [resolved.root, resolved.modelsDir, blobs]) {
const info = await lstat(folder);
if (info.isSymbolicLink() || !info.isDirectory()) return 0;
}
const names = await readdir(blobs);
for (const name of names.slice(0, STORE_MEASURE_MAX_FILES)) {
try {
const info = await lstat(join(blobs, name));
if (info.isFile()) total += info.size;
} catch {
// Gone between the listing and the look: a download or a delete is under way.
}
}
} catch {
// No model store yet.
}
return total;
}
// ---------- Port ----------
/** Whether nothing holds `port` on 127.0.0.1, decided by binding it and letting go again. */
-39
View File
@@ -26,7 +26,6 @@ import {
type LocalModelErrorCode,
type LocalModelInstallState,
type LocalModelInstallStep,
type LocalModelLibraryEntry,
type LocalModelStateFile,
} from '../types.js';
import { OLLAMA_RUNTIME_PIN } from './runtime.js';
@@ -154,41 +153,6 @@ function parseConnection(value: unknown): LocalModelStateFile['connection'] {
: undefined;
}
/** How many checked models a state file may list: far more than one Mac holds, and a bound on what a damaged file can cost. */
const MAX_LIBRARY_ENTRIES = 64;
/**
* The checked models of a file. An entry without its id, label, context or digest is dropped
* (it could never be switched to), and so is a second entry for an id. `undefined` for a file
* that has no list, which is what an earlier build wrote.
*/
function parseLibrary(value: unknown): LocalModelLibraryEntry[] | undefined {
if (!Array.isArray(value)) return undefined;
const entries: LocalModelLibraryEntry[] = [];
for (const item of value) {
if (entries.length >= MAX_LIBRARY_ENTRIES) break;
if (!isRecord(item)) continue;
const id = text(item.id, 200);
const label = text(item.label, 200);
const context = count(item.context);
const digest = text(item.digest, 200);
if (!id || !label || !context || !digest || entries.some((entry) => entry.id === id)) continue;
const bytes = count(item.bytes);
const cli = parseCli(item.cli);
const verifiedAt = text(item.verifiedAt, 64);
entries.push({
id,
label,
context,
digest,
...(bytes ? { bytes } : {}),
...(cli ? { cli } : {}),
...(verifiedAt ? { verifiedAt } : {}),
});
}
return entries;
}
/**
* The state a file's text stands for. Pure and total: text that is not a JSON object of
* `schema: 1` with a known `state` is `IDLE_LOCAL_MODEL_STATE`. Members it does not know are
@@ -216,7 +180,6 @@ export function parseLocalModelState(raw: string): LocalModelStateFile {
const connection = parseConnection(parsed.connection);
const updatedAt = text(parsed.updatedAt, 64);
const replacedModelId = text(parsed.replacedModelId, 200);
const library = parseLibrary(parsed.library);
if (!model || !cli) return IDLE_LOCAL_MODEL_STATE;
if (state === 'installed') {
@@ -229,7 +192,6 @@ export function parseLocalModelState(raw: string): LocalModelStateFile {
cli,
runtime,
...(loaded ? { loaded } : {}),
...(library ? { library } : {}),
...(connection ? { connection } : {}),
...(updatedAt ? { updatedAt } : {}),
};
@@ -253,7 +215,6 @@ export function parseLocalModelState(raw: string): LocalModelStateFile {
...(runtime ? { runtime } : {}),
...(progress ? { progress } : {}),
...(loaded ? { loaded } : {}),
...(library ? { library } : {}),
...(connection ? { connection } : {}),
...(replacedModelId ? { replacedModelId } : {}),
...(updatedAt ? { updatedAt } : {}),
+3
View File
@@ -21,6 +21,7 @@ import type {
PiConfig,
GrokConfig,
CopilotConfig,
JcodeConfig,
DeepSeekConfig,
OmpConfig,
SessionRemote,
@@ -127,6 +128,7 @@ export interface CreateSessionOptions {
piConfig?: PiConfig;
grokConfig?: GrokConfig;
copilotConfig?: CopilotConfig;
jcodeConfig?: JcodeConfig;
deepSeekConfig?: DeepSeekConfig;
ompConfig?: OmpConfig;
/** When restoring after reboot, resume a previous Claude conversation by its session ID */
@@ -179,6 +181,7 @@ export interface RespawnPaneOptions {
piConfig?: PiConfig;
grokConfig?: GrokConfig;
copilotConfig?: CopilotConfig;
jcodeConfig?: JcodeConfig;
deepSeekConfig?: DeepSeekConfig;
ompConfig?: OmpConfig;
/** Resume a previous Claude conversation when respawning */
+33
View File
@@ -85,3 +85,36 @@ export function collectDescendants(
}
return out;
}
/**
* The descendants a session kill may signal when the pane's CLI runs a SHARED daemon
* (`capabilities.sharedDaemon`): only those still in the pane's own process group.
*
* Such a CLI's first client starts a per-user daemon that every other session of that
* CLI talks to, and the daemon detaches itself into its own session (setsid), which also
* makes it the leader of a new process group. It stays a descendant of that first pane
* by parent pid, though, so the plain descendant kill took it down with the pane and cut
* every other session off (measured with jcode: deleting the tab that happened to start
* the daemon left the next tab's client reconnecting forever). A process that left the
* pane's group did so on purpose, and its whole subtree inherits the new group, so the
* group is what draws the line.
*
* A pid whose group could not be read is KEPT in the kill list: it most likely exited
* between the walk and the lookup, and the kill path already checks liveness before it
* signals. Callers that could not read the groups at all keep the full list, which is
* the behaviour before this existed.
*
* @param descendants the walk's result for the pane pid
* @param groupOf pgid per pid, as read once by the caller
* @param paneGroup the pane process's own pgid
*/
export function descendantsInPaneGroup(
descendants: readonly number[],
groupOf: ReadonlyMap<number, number>,
paneGroup: number
): number[] {
return descendants.filter((pid) => {
const group = groupOf.get(pid);
return group === undefined || group === paneGroup;
});
}
+2
View File
@@ -35,6 +35,7 @@ import type {
GeminiConfig,
GrokConfig,
CopilotConfig,
JcodeConfig,
OmpConfig,
OpenCodeConfig,
PiConfig,
@@ -53,6 +54,7 @@ export interface SpawnBridgeOptions {
piConfig?: PiConfig;
grokConfig?: GrokConfig;
copilotConfig?: CopilotConfig;
jcodeConfig?: JcodeConfig;
deepSeekConfig?: DeepSeekConfig;
ompConfig?: OmpConfig;
resumeSessionId?: string;
+14
View File
@@ -58,6 +58,7 @@ import {
type PiConfig,
type GrokConfig,
type CopilotConfig,
type JcodeConfig,
type DeepSeekConfig,
type OmpConfig,
type SessionRemote,
@@ -864,6 +865,8 @@ export class Session extends EventEmitter {
private _grokConfig: GrokConfig | undefined;
// GitHub Copilot configuration (only for mode === 'copilot')
private _copilotConfig: CopilotConfig | undefined;
// jcode configuration (only for mode === 'jcode')
private _jcodeConfig: JcodeConfig | undefined;
// DeepSeek Harness configuration (only for mode === 'deepseek')
private _deepSeekConfig: DeepSeekConfig | undefined;
@@ -1003,6 +1006,8 @@ export class Session extends EventEmitter {
/** Grok configuration (only for mode === 'grok') */
grokConfig?: GrokConfig;
copilotConfig?: CopilotConfig;
/** jcode configuration (only for mode === 'jcode') */
jcodeConfig?: JcodeConfig;
/** DeepSeek Harness configuration (only for mode === 'deepseek') */
deepSeekConfig?: DeepSeekConfig;
/** OMP configuration (only for mode === 'omp') */
@@ -1197,6 +1202,11 @@ export class Session extends EventEmitter {
this._copilotConfig = config.copilotConfig;
}
// Apply jcode configuration
if (config.jcodeConfig) {
this._jcodeConfig = config.jcodeConfig;
}
// Apply env overrides (exported at spawn, not persisted to disk).
// Legacy migration: pre-0.7.2 carried effort as the CLAUDE_CODE_EFFORT_LEVEL env var,
// which hard-locks /effort switching. Extract it into _effort (--settings soft default)
@@ -2512,6 +2522,7 @@ export class Session extends EventEmitter {
piConfig: this._piConfig,
grokConfig: this._grokConfig,
copilotConfig: this._copilotConfig,
jcodeConfig: this._jcodeConfig,
deepSeekConfig: this._deepSeekConfig,
ompConfig: this._ompConfig,
resumeSessionId: this._resumeSessionId,
@@ -2893,6 +2904,7 @@ export class Session extends EventEmitter {
piConfig: this._piConfig,
grokConfig: this._grokConfig,
copilotConfig: this._copilotConfig,
jcodeConfig: this._jcodeConfig,
deepSeekConfig: this._deepSeekConfig,
// OMP resolution/pinning does NOT happen here. This object is built
// EAGERLY — including on every boot-recovery reattach, before anyone
@@ -3411,6 +3423,7 @@ export class Session extends EventEmitter {
piConfig: this._piConfig,
grokConfig: this._grokConfig,
copilotConfig: this._copilotConfig,
jcodeConfig: this._jcodeConfig,
deepSeekConfig: this._deepSeekConfig,
ompConfig: this._ompConfig,
resumeSessionId: this._resumeSessionId,
@@ -3994,6 +4007,7 @@ export class Session extends EventEmitter {
piConfig: this._piConfig,
grokConfig: this._grokConfig,
copilotConfig: this._copilotConfig,
jcodeConfig: this._jcodeConfig,
deepSeekConfig: this._deepSeekConfig,
ompConfig: this._ompConfig,
};
+50 -4
View File
@@ -22,7 +22,7 @@
*/
import { EventEmitter } from 'node:events';
import { collectDescendants } from './proc-tree.js';
import { collectDescendants, descendantsInPaneGroup } from './proc-tree.js';
import {
foreignViewSessionName,
buildForeignDockerViewKillCommand,
@@ -61,6 +61,7 @@ import {
type PiConfig,
type GrokConfig,
type CopilotConfig,
type JcodeConfig,
type DeepSeekConfig,
type OmpConfig,
type SessionRemote,
@@ -1213,6 +1214,7 @@ export function buildSpawnCommand(options: {
piConfig?: PiConfig;
grokConfig?: GrokConfig;
copilotConfig?: CopilotConfig;
jcodeConfig?: JcodeConfig;
deepSeekConfig?: DeepSeekConfig;
ompConfig?: OmpConfig;
resumeSessionId?: string;
@@ -2563,6 +2565,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
piConfig,
grokConfig,
copilotConfig,
jcodeConfig,
deepSeekConfig,
ompConfig,
resumeSessionId,
@@ -2662,6 +2665,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
piConfig,
grokConfig,
copilotConfig,
jcodeConfig,
deepSeekConfig,
ompConfig,
resumeSessionId,
@@ -2931,6 +2935,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
piConfig,
grokConfig,
copilotConfig,
jcodeConfig,
deepSeekConfig,
ompConfig,
resumeSessionId,
@@ -2982,6 +2987,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
piConfig,
grokConfig,
copilotConfig,
jcodeConfig,
deepSeekConfig,
ompConfig,
resumeSessionId,
@@ -3160,6 +3166,45 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
});
}
/**
* Narrow a pane's descendants to the ones a kill may signal, for a CLI that runs a
* shared daemon (`capabilities.sharedDaemon`): those still in the pane's process group
* (see `descendantsInPaneGroup()`). One bounded `ps` for the pane and its descendants,
* as an argv (no shell). Any other CLI, or a lookup that failed, gets the list back
* unchanged, which is the kill as it always was.
*/
private async killableDescendants(mode: SessionMode, panePid: number, descendants: number[]): Promise<number[]> {
if (descendants.length === 0 || !getCli(mode)?.capabilities.sharedDaemon) return descendants;
try {
const { stdout } = await new Promise<{ stdout: string }>((resolve, reject) => {
execFile(
'ps',
['-o', 'pid=,pgid=', '-p', [panePid, ...descendants].join(',')],
{ timeout: EXEC_TIMEOUT_MS },
// `ps -p` exits non-zero when some pid is already gone; what it printed still counts.
(err, out) => (out ? resolve({ stdout: out }) : reject(err ?? new Error('ps printed nothing')))
);
});
const groupOf = new Map<number, number>();
for (const line of stdout.split('\n')) {
const [pid, pgid] = line.trim().split(/\s+/).map(Number);
if (Number.isInteger(pid) && Number.isInteger(pgid)) groupOf.set(pid, pgid);
}
const paneGroup = groupOf.get(panePid);
if (paneGroup === undefined) return descendants;
const killable = descendantsInPaneGroup(descendants, groupOf, paneGroup);
if (killable.length < descendants.length) {
console.log(
`[TmuxManager] Sparing ${descendants.length - killable.length} process(es) outside pane ${panePid}'s group (the CLI's shared daemon)`
);
}
return killable;
} catch (err) {
console.warn(`[TmuxManager] Could not read process groups for pane ${panePid}; killing every descendant:`, err);
return descendants;
}
}
// Check if a process is still alive. Signal decisions only: an unreaped zombie
// counts here, so its process group still gets the SIGKILL below. The WAITS use
// waitForProcessesExit(), which counts a zombie as exited.
@@ -3308,8 +3353,9 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
const allPids: number[] = [currentPid];
// Strategy 1: Kill all child processes recursively
let childPids = await this.getChildPidsFresh(currentPid);
// Strategy 1: Kill all child processes recursively (but never a shared daemon the
// pane's CLI started for every session of that CLI: see killableDescendants)
let childPids = await this.killableDescendants(session.mode, currentPid, await this.getChildPidsFresh(currentPid));
if (childPids.length > 0) {
console.log(`[TmuxManager] Found ${childPids.length} child processes to kill`);
allPids.push(...childPids);
@@ -3328,7 +3374,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
// catch anything spawned since the first one.
await waitForProcessesExit(childPids, { timeoutMs: TMUX_KILL_WAIT_MS });
childPids = await this.getChildPidsFresh(currentPid);
childPids = await this.killableDescendants(session.mode, currentPid, await this.getChildPidsFresh(currentPid));
for (const childPid of childPids) {
if (this.isProcessAlive(childPid)) {
try {
+1
View File
@@ -1062,6 +1062,7 @@ const MODE_ITEMS: ReadonlyArray<{ id: TuiRunMode; label: string; detail: string
{ id: 'pi', label: 'pi', detail: 'pi.dev' },
{ id: 'grok', label: 'grok', detail: 'xAI Grok Build' },
{ id: 'deepseek', label: 'deepseek', detail: 'DeepSeek Harness (dsh)' },
{ id: 'jcode', label: 'jcode', detail: 'jcode' },
];
// ─────────────────────────────────────────────────────────────────────────────
+1 -1
View File
@@ -155,7 +155,7 @@ export type TuiAnswerResult =
export interface TuiQuickStartOptions {
caseName: string;
mode?: 'claude' | 'shell' | 'opencode' | 'codex' | 'gemini' | 'antigravity' | 'pi' | 'grok' | 'deepseek';
mode?: 'claude' | 'shell' | 'opencode' | 'codex' | 'gemini' | 'antigravity' | 'pi' | 'grok' | 'deepseek' | 'jcode';
sessionName?: string;
/** The tab this spawn came from, for the lineage lines (cosmetic, dropped if unresolvable). */
parentSessionId?: string;
+13 -144
View File
@@ -24,11 +24,7 @@
* | `GET /api/local-model` | none | none (a failed probe is an answer) |
* | `POST /api/local-model/install` | `LocalModelInstallRequest` | 400 not a row the server offers, or a saved endpoint |
* | | | already has the reserved id; 409 `job-running`, |
* | | | `already-installed` (this model at this context is |
* | | | the one in use), `size-changed`, `sessions-open` |
* | `POST /api/local-model/use` | `LocalModelUseRequest` | 400 not a checked model of the library, or no coding |
* | | | agent for it is installed; 409 `job-running`, |
* | | | `wrong-state`, `already-installed`, `sessions-open` |
* | | | `already-installed`, `size-changed` |
* | `POST /api/local-model/cancel` | none | 409 `wrong-state` (no job is running) |
* | `POST /api/local-model/resume` | `LocalModelResumeRequest` | 409 `job-running`, `wrong-state` (not paused or |
* | | | failed), `sessions-open` (another port while live |
@@ -37,9 +33,6 @@
* | | | `sessions-open` (stop, or another port, while live |
* | | | sessions use the model) |
* | `DELETE /api/local-model` | `LocalModelRemoveRequest` | 409 `job-running`, `sessions-open` |
* | `DELETE /api/local-model/models` | `LocalModelDeleteRequest` | 400 not a model of this Mac; 409 `job-running`, |
* | | | `wrong-state`, `model-active`, `runtime-not-running`; |
* | | | 500 the runtime did not delete it |
*
* A 409 is the error envelope plus `data: LocalModelConflict`:
* `{ success: false, error, errorCode: 'CONFLICT', data: { reason, message, ... } }`. A 409
@@ -148,10 +141,7 @@ export const LOCAL_MODEL_INSTALL_STEPS: readonly LocalModelInstallStep[] = [
* | `harness-failed` | the coding agent's install command failed, or its binary is still missing |
* | `endpoint-id-taken` | a saved endpoint with our id exists that this feature did not write |
* | `connect-failed` | the endpoint record or the settings flag could not be written |
* | `model-changed` | installed: the model's digest is not the one stored at install time. A |
* | | switch: the model's digest is not the one it was checked with |
* | `job-unfinished` | a job that changes the model in use is running or stopped. Only a |
* | | refused session start says it: nothing is started until the job ended |
* | `model-changed` | installed: the model's digest is not the one stored at install time |
* | `internal` | anything unexpected (the exception text is in `detail`) |
*/
export type LocalModelErrorCode =
@@ -174,7 +164,6 @@ export type LocalModelErrorCode =
| 'endpoint-id-taken'
| 'connect-failed'
| 'model-changed'
| 'job-unfinished'
| 'internal';
/**
@@ -203,25 +192,12 @@ export type LocalModelNextStep = 'retry' | 'other-port' | 'next-model' | 'start'
* - `cancel`: `POST /api/local-model/cancel`.
* - `resume`: `POST /api/local-model/resume`.
* - `start` and `stop`: `POST /api/local-model/runtime`.
* - `remove`: `DELETE /api/local-model`. In `idle` it is listed only while the model store
* still holds something (models an earlier Remove kept): it is then what frees that space.
* - `remove`: `DELETE /api/local-model`.
* - `start-session`: not a route of this feature: the window starts a session on the endpoint
* `endpointId` with the coding agent `cli`. Offered only while the runtime runs and nothing
* blocks a session.
* - `use`: `POST /api/local-model/use`. Listed only while a row of `library` offers it.
* - `delete-model`: `DELETE /api/local-model/models`. Listed only while a row of `library`
* offers it.
*/
export type LocalModelAction =
| 'install'
| 'cancel'
| 'resume'
| 'start'
| 'stop'
| 'remove'
| 'start-session'
| 'use'
| 'delete-model';
export type LocalModelAction = 'install' | 'cancel' | 'resume' | 'start' | 'stop' | 'remove' | 'start-session';
/** Whether the Mac has the developer tools (git, Python) a coding agent calls. */
export type LocalModelDeveloperTools =
@@ -249,9 +225,7 @@ export type LocalModelDeveloperTools =
* | `errHarnessFailed` | `cli`: the coding agent's label |
* | `errEndpointIdTaken` | `id`: the endpoint id this feature uses |
* | `refusedAgentNotInstallable` | `cli`: the coding agent's label |
* | `refusedOtherModel` | `model`: the label of the model that is in use |
* | `refusedAgentMissing` | `cli`: the coding agent's label |
* | `conflictModelActive`, `deleteFailed` | `model`: the model's label |
* | `refusedOtherModel` | `model`: the label of the model that is installed |
* | `conflictSizeChanged` | `size`: the server's own total as decimal GB |
* | `conflictSessionsOpen` | `sessions`: the session names, joined with ', ' |
*
@@ -285,23 +259,16 @@ export const LOCAL_MODEL_INSTALL_LABELS = {
'A saved model connection named {id} already exists and was not created here. Rename or delete it in App Settings, then try again.',
errConnectFailed: 'Codeman could not save the connection to the model.',
errModelChanged: 'The model on this Mac changed since it was installed. Remove it and install it again.',
errJobUnfinished:
'The model on this Mac is being changed and is not ready. Finish or undo that in the local model window first.',
errInternal: 'The install stopped on an unexpected error.',
conflictJobRunning: 'An install is already running.',
conflictAlreadyInstalled: 'This model is already in use on this Mac.',
conflictModelActive: '{model} is the model in use. Switch to another model first, or remove the local model.',
conflictAlreadyInstalled: 'A model is already installed on this Mac. Remove it first.',
conflictSizeChanged: 'The download is {size} GB now. Check the sizes and confirm again.',
conflictSessionsOpen: 'Sessions are still using this model: {sessions}. Close them first.',
conflictWrongState: 'That is not possible in the current state.',
refusedModelNotOffered: 'This model at this context is not offered for this Mac right now.',
refusedAgentNotOffered: 'This coding agent is not offered for this model.',
refusedAgentNotInstallable: '{cli} is not installed and cannot be installed from here.',
refusedOtherModel: 'Only {model} is in use on this Mac. Switch models in the local model window first.',
refusedNotInLibrary: 'This model is not one of the checked models on this Mac.',
refusedNotOnThisMac: 'This model is not on this Mac.',
deleteFailed: '{model} could not be deleted from this Mac.',
refusedAgentMissing: '{cli} is not installed on this Mac. Install this model again to choose a coding agent.',
refusedOtherModel: 'Only {model} is installed on this Mac. Remove it and install another model first.',
endpointManaged:
'This endpoint belongs to the model Codeman runs on this Mac. Change or remove it in the local model window.',
endpointIdReserved: 'This endpoint id is reserved for the model Codeman runs on this Mac. Pick another id.',
@@ -340,7 +307,6 @@ export const LOCAL_MODEL_ERROR_LABELS: Record<LocalModelErrorCode, LocalModelIns
'endpoint-id-taken': LOCAL_MODEL_INSTALL_LABELS.errEndpointIdTaken,
'connect-failed': LOCAL_MODEL_INSTALL_LABELS.errConnectFailed,
'model-changed': LOCAL_MODEL_INSTALL_LABELS.errModelChanged,
'job-unfinished': LOCAL_MODEL_INSTALL_LABELS.errJobUnfinished,
internal: LOCAL_MODEL_INSTALL_LABELS.errInternal,
};
@@ -365,7 +331,6 @@ export const LOCAL_MODEL_ERROR_NEXT: Record<LocalModelErrorCode, LocalModelNextS
'endpoint-id-taken': 'retry',
'connect-failed': 'retry',
'model-changed': 'remove',
'job-unfinished': 'retry',
internal: 'retry',
};
@@ -461,8 +426,7 @@ export interface LocalModelLoadedFacts {
* | `port` | | once chosen | once chosen | once chosen | yes |
* | `loaded`, `endpointId` | | | | | yes |
*
* `running`, `developerTools`, `runtime`, `folder`, `library` and `actions` are always
* present. `store` is present once the model store was measured.
* `running`, `developerTools`, `runtime`, `folder` and `actions` are always present.
*/
export interface LocalModelInstallView {
state: LocalModelInstallState;
@@ -490,63 +454,10 @@ export interface LocalModelInstallView {
loaded?: LocalModelLoadedFacts;
/** The id of the saved endpoint a session is started on. */
endpointId?: string;
/**
* The models on this Mac: every model Codeman checked (`LocalModelStateFile.library`), plus
* whatever else the running runtime lists. Empty in `idle` (no runtime is there to ask) and
* before anything was downloaded. The model in use comes first.
*/
library: LocalModelLibraryRow[];
/** What the model store takes on disk right now. Present in every state once it was measured. */
store?: LocalModelStoreView;
/** What the server accepts right now, see `LocalModelAction`. */
actions: LocalModelAction[];
}
/**
* How a model of the library stands.
* - `ready`: checked by Codeman, and the runtime (when it was asked) still lists it with the
* digest that was stored.
* - `changed`: checked, but the runtime now lists another digest under the name. It is never
* started: `use` would fail with `model-changed`.
* - `missing`: checked, but the runtime no longer lists it (something deleted it through the
* runtime's port).
* - `unverified`: the runtime lists it and Codeman never checked it: a model an earlier
* Remove kept, the model of a job that has not passed its check yet, or one another program
* pulled through the runtime's port. Never startable.
*/
export type LocalModelLibraryStatus = 'ready' | 'changed' | 'missing' | 'unverified';
/** What can be done with one row of `library`: `POST /api/local-model/use` and `DELETE /api/local-model/models`. */
export type LocalModelLibraryAction = 'use' | 'delete';
/** One model on this Mac. */
export interface LocalModelLibraryRow {
/** The model's name exactly as the runtime lists it (`name:tag`). */
id: string;
/** Its catalogue label, or `id` for a model the catalogue does not know. */
label: string;
/** The context it was checked with. Absent for an `unverified` row. */
context?: number;
/** Its size on disk: what the runtime reports, else what was recorded when it was checked. */
sizeBytes?: number;
/** `sizeBytes` as decimal GB text, no unit. Present exactly when `sizeBytes` is. */
sizeGb?: string;
/** It is the model in use: the one of an `installed` state. */
active: boolean;
/** Codeman checked it (`LocalModelStateFile.library` holds it). */
verified: boolean;
status: LocalModelLibraryStatus;
/** What the server accepts for this row right now. `delete` needs the running runtime. */
actions: LocalModelLibraryAction[];
}
/** The disk use of the model store: the files directly in its blob folder, summed. */
export interface LocalModelStoreView {
bytes: number;
/** `bytes` as decimal GB text, no unit. */
gb: string;
}
/** The `data` of `GET /api/local-model`. Admin only in multi-user mode. */
export interface LocalModelStatus {
hardware: HardwareFacts;
@@ -600,18 +511,6 @@ export interface LocalModelRemoveRequest {
removeModel?: boolean;
}
/** Body of `POST /api/local-model/use`: a checked model of `library` to switch to. Nothing is downloaded. */
export interface LocalModelUseRequest {
/** `LocalModelLibraryRow.id` of a row whose `actions` list `use`. */
modelId: string;
}
/** Body of `DELETE /api/local-model/models`: one model to delete from the store. */
export interface LocalModelDeleteRequest {
/** `LocalModelLibraryRow.id` of a row whose `actions` list `delete`. Never a path. */
modelId: string;
}
/** The `data` of a 400 the service names: its sentence, for the window to print in its own language. */
export interface LocalModelRefusal {
message: LocalModelInstallMessage;
@@ -623,26 +522,20 @@ export type LocalModelVerbAnswer = LocalModelInstallView;
/**
* Why a verb was refused with 409.
* - `job-running`: a job is running (a second install, a resume, a remove or a runtime action).
* - `already-installed`: install or use of the model that is already the one in use (at the
* same context).
* - `already-installed`: install while a model is installed. Remove it first.
* - `size-changed`: `acknowledgedBytes` is not the server's total; `expectedBytes` and
* `expectedGb` carry that total, so the window can show it and ask again.
* - `sessions-open`: remove, stop, a change of port, an install of another model or a switch
* while live sessions use the endpoint; `sessions` names them. Their coding agents were
* given the runtime's address and the model's name when they started, so they would go on
* calling a port nothing of ours holds, or make the runtime swap models on every request.
* - `sessions-open`: remove, stop or a change of port while live sessions use the endpoint;
* `sessions` names them. Their coding agents were given the runtime's address when they
* started, so they would go on calling a port nothing of ours holds.
* - `wrong-state`: the verb makes no sense in the current state (see the route table above).
* - `model-active`: a delete of the model the installation or the current job is about.
* - `runtime-not-running`: a delete while the runtime is down: only the runtime deletes a model.
*/
export type LocalModelConflictReason =
| 'job-running'
| 'already-installed'
| 'size-changed'
| 'sessions-open'
| 'wrong-state'
| 'model-active'
| 'runtime-not-running';
| 'wrong-state';
/** The `data` of a 409 of an install verb. */
export interface LocalModelConflict {
@@ -729,14 +622,6 @@ export interface LocalModelStateFile {
progress?: { completedBytes: number; totalBytes: number };
/** What the runtime reported when the job checked the loaded model. */
loaded?: { context: number; sizeBytes: number; sizeVramBytes: number };
/**
* Every model that PASSED the check through Codeman and is believed to be in the store, the
* model in use among them. An entry is written when the `verify` step passes and dropped
* when the model is deleted; `remove` drops the whole list. An `installed` file an earlier
* build wrote has none: its one model then counts as the library (`libraryOf()` in
* src/local-model/install-job.ts).
*/
library?: LocalModelLibraryEntry[];
/**
* Present once the `connect` step CLAIMED the connection, which it does before its two
* writes: what uninstall must undo. A claim with nothing behind it (the step was interrupted
@@ -751,19 +636,3 @@ export interface LocalModelStateFile {
/** ISO time of the last write. */
updatedAt?: string;
}
/** One checked model of `LocalModelStateFile.library`. It holds no path. */
export interface LocalModelLibraryEntry {
id: string;
label: string;
/** The context length in tokens it was checked with. */
context: number;
/** The digest `/api/tags` reported when it passed the check. A switch to it must find the same one. */
digest: string;
/** Its size on disk as the runtime reported it then, for a listing while the runtime is stopped. */
bytes?: number;
/** The coding agent of the job that checked it. A switch back takes it when it is installed. */
cli?: { id: string; label: string };
/** ISO time of the check. */
verifiedAt?: string;
}
-6
View File
@@ -554,12 +554,6 @@ export interface Candidate {
installBytes: number;
/** `installBytes` as decimal GB text, no unit. */
installGb: string;
/**
* Set by the route, never by `recommend()`: this model at this context is a checked model of
* the library on this Mac (`LocalModelInstallView.library`). `active`: it is the model in
* use. `ready`: `POST /api/local-model/use` switches to it, and nothing is downloaded.
*/
library?: 'active' | 'ready';
}
/**
+48 -4
View File
@@ -8,7 +8,7 @@
* - SessionConfig — creation-time config (id, workingDir, createdAt)
* - SessionOutput — captured stdout/stderr/exitCode
* - SessionStatus — 'idle' | 'busy' | 'stopped' | 'error'
* - SessionMode — 'claude' | 'shell' | 'opencode' | 'codex' | 'gemini' | 'antigravity' | 'pi' | 'grok' | 'deepseek' | 'omp' | 'copilot' (which CLI backend)
* - SessionMode — 'claude' | 'shell' | 'opencode' | 'codex' | 'gemini' | 'antigravity' | 'pi' | 'grok' | 'deepseek' | 'omp' | 'copilot' | 'jcode' (which CLI backend)
* - ClaudeMode — CLI permission mode ('dangerously-skip-permissions' | 'auto' | 'normal' | 'allowedTools')
* - SessionColor — visual differentiation color
* - OpenCodeConfig — OpenCode-specific settings (model, autoAllowTools, continueSession)
@@ -19,6 +19,7 @@
* - GrokConfig — Grok Build CLI (xAI `grok`) settings (model, alwaysApprove, resume/continue)
* - CopilotConfig — GitHub Copilot CLI settings (model, allowAll, resume/continue)
* - DeepSeekConfig — DeepSeek Harness (`dsh`) settings (profile, permissionMode, resume, status bridge)
* - JcodeConfig: jcode settings (model, resume)
*
* Cross-domain relationships:
* - SessionState.respawnConfig embeds RespawnConfig (respawn domain)
@@ -60,7 +61,8 @@ export type SessionMode =
| 'grok'
| 'deepseek'
| 'omp'
| 'copilot';
| 'copilot'
| 'jcode';
/**
* Who owns a session's name. `placeholder`: Codeman's own `w<n>-<case>` (or no
@@ -82,7 +84,18 @@ export interface SessionWriteOptions {
export type RemoteCommandMode = Extract<
SessionMode,
'shell' | 'claude' | 'opencode' | 'codex' | 'gemini' | 'antigravity' | 'pi' | 'grok' | 'deepseek' | 'omp' | 'copilot'
| 'shell'
| 'claude'
| 'opencode'
| 'codex'
| 'gemini'
| 'antigravity'
| 'pi'
| 'grok'
| 'deepseek'
| 'omp'
| 'copilot'
| 'jcode'
>;
/**
@@ -217,7 +230,18 @@ export interface RemoteSessionInfo {
/** Which CLI backends a Docker case can run (same set as remote). */
export type DockerCommandMode = Extract<
SessionMode,
'shell' | 'claude' | 'opencode' | 'codex' | 'gemini' | 'antigravity' | 'pi' | 'grok' | 'deepseek' | 'omp' | 'copilot'
| 'shell'
| 'claude'
| 'opencode'
| 'codex'
| 'gemini'
| 'antigravity'
| 'pi'
| 'grok'
| 'deepseek'
| 'omp'
| 'copilot'
| 'jcode'
>;
/** Container engine. Docker and Podman differ in the uid/userns + host-gateway alias. */
@@ -631,6 +655,24 @@ export interface CopilotConfig {
resumeSessionId?: string;
}
/**
* jcode (1jehuang/jcode) session configuration.
*
* jcode has no approval prompts and no bypass flag, so there is nothing
* privileged to configure; the provider and its login come from jcode's own
* `~/.jcode/config.toml`. Verified against jcode 0.98.1.
*/
export interface JcodeConfig {
/** Model ID passed via --model (e.g. "claude-opus-4-8", "gpt-5.5"). */
model?: string;
/**
* Resume a jcode session (--resume): its id (`session_fox_<ms>_<hex>`) or its
* short name (`fox`), as ONE plain word starting with a letter or digit, since
* --resume's value is optional and a leading `-` would make it its own flag.
*/
resumeSessionId?: string;
}
/**
* DeepSeek Harness (`dsh`) session configuration.
*
@@ -1106,6 +1148,8 @@ export interface SessionState {
deepSeekConfig?: DeepSeekConfig;
/** OMP-specific configuration (only for mode === 'omp') */
ompConfig?: OmpConfig;
/** jcode configuration (only for mode === 'jcode') */
jcodeConfig?: JcodeConfig;
/** Claude conversation session ID to resume after reboot (set by restore script) */
resumeSessionId?: string;
/** Claude CLI effort level (soft default via --settings, switchable in-session via /effort) */
+1 -1
View File
@@ -48,7 +48,7 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L
**Opened outside Codeman** (`foreign-sessions.js`, both home screens): the tmux sessions a human started, listed from `GET /api/mux/foreign` (root CLAUDE.md, Adopted tmux sessions). ONE renderer, `renderForeignSessions(container)`, fills the welcome block and the phone overview's, and every row goes through `textContent`, since a session name and a command line come from someone else's process. ⚠️ It polls only while a home screen is up (`startForeignPolling`/`stopForeignPolling` from `showWelcome()`/`hideWelcome()`) and the tab is visible (`_foreignPollTick()` is the one gate, and `visibilitychange` catches up; the cache TTL sits under the poll interval, so one tab's every tick is a full socket sweep), and the poll is local only: the docker and ssh scan costs one connection per target, so it runs once on the toggle and again on Rescan, and its rows and notes are kept apart (`_foreignWideSessions`, `_foreignWideNotes`) or the next local poll would wipe them. ⚠️ A 403 is a non-admin in multi-user mode, an answer and not a failed poll: `_foreignRefused` stops the polling and shows no block. ⚠️ `closeSession()` re-reads the list after an adopted tab's DELETE, since the home screen's own load races it and the row would say `Go to tab` until the next poll. ⚠️ An adopted tab has no kill outcome to offer, so the close dialog hides the red button and always tears the wrapper down. ⚠️ `session.remote` and `session.docker` are undefined on an adopted session, so a check that hides a row for those two usually needs `session.adopt` as well. Tests: `test/foreign-sessions-ui.test.ts`, `test/foreign-sessions-i18n.test.ts` (harvests every string from the real module).
**Run a model on this Mac** (`local-model-ui.js`; welcome button `#welcomeLocalModelBtn`, chooser window `#localModelModal`, App Settings > Models row `#localModelSettings`; server rules in the root CLAUDE.md, Local model): ONE window does everything (choose, confirm, progress, paused, failed, installed, no recommendation); the button and the Settings row only open it. ⚠️ The models on this Mac (`install.library`) are ONE list builder, `_localModelLibraryEl()`: on the installed screen always (the model in use first, in the chooser's selected look, each row with the context it was checked with, a state tag and its size on disk), on a paused or failed screen only when a row offers something (the way back to an earlier model), never on the screen that ends an install. A row has "Use" and "Delete" only for what the server lists for THAT row (`row.actions`); Use is a switch with no confirm screen (nothing is downloaded), Delete asks once in place under its row (`.lm-lib-ask`, the one red button, the focus on Cancel), and the asked row keeps its buttons unseen (`.lm-lib-actions-away`) so the sizes do not jump sideways. Under the list: what all downloads take (`install.store`) and "Add a model", which opens the chooser from an installed model; there a row with `candidate.library` says "In use" or "On this Mac" and the main button becomes a disabled "In use" or "Use this model". With nothing installed and a store an earlier Remove kept, the chooser and the nothing-fits screen say what it takes and offer "Delete them" (`_localModelStoreEl()`). ⚠️ A screen that carries the list sets `data-long` on the body, which switches it from the centred short-screen layout (bottom-aligned on a phone) to top-aligned: a centred flex column that overflows cuts off its first lines. ⚠️ The row buttons live in `.lm-lib-actions`, not `.lm-buttons` (the screen's own buttons in the foot), and are a size smaller (`.lm-btn-row`). ⚠️ A job whose steps hold no `model` step is a switch (`localModelIsSwitch()`) and is named one ("Switching to", no download bar). Which screen shows follows `install.state` of `GET /api/local-model`; the page itself holds only the selection and whether the person is on the chooser or the confirm screen. ⚠️ The window is ONE frame for the whole flow: a fixed height on desktop (`.modal-content.local-model-window`), App Settings' boxed close button, the body scrolling between the header and a foot of its own (`#localModelWindowFoot`): every screen's last part (`.lm-foot`, or the `.lm-remove` question) is moved there by `_renderLocalModelWindow()`, so a button never moves away from under the pointer and is never clipped; a test that looks for a button looks in the frame, not the body. The short screens are centred in that frame on desktop and sit above the buttons on a phone (`data-screen` on the body). ⚠️ The memory bar (`.lm-bar`, `role="meter"`) is the one strong element: filled to the selected row's `budgetShare`, the model at its PEAK (the legend says so, and the installed line uses the same words beside what the model takes now, so the two figures are never read as a contradiction), the memory map (`Candidate.memory`: the model, its context and the brief peaks side by side, each named in the legend, on a track of `--text-muted` at about a third) in the feature's own colours (`--lm-*`: white for the feature, turned over to dark ink on the light skins, blue the context, green good, amber tight, orange slow, rose the brief peaks, deeper on the light skins), never `--accent`, which the owner rejected as too much blue (about 3:1 against the window, so the free part can be seen; a border token is 1.5:1 and the inputs' `--bg-input` is the window's own surface on the dark skins), 10px, no gradient or glow, and its width transition is the only motion (off under `prefers-reduced-motion`); a repaint starts each bar at the last width of its kind, so keep `_settleLocalModelBars()` after every body write. The figure beside it is an estimate and says "about" in whole GB (`localModelWholeGb()` on the server's text); only the download figure keeps tabular figures. ⚠️ The rows are the server's pick first, then the other models by download size, largest first (`localModelCandidates()`): the server's alternatives are its ranking, which no column shows. ⚠️ The download bar means ONE thing, the model download: at the start while the runtime is fetched, gone once the model is in (the step list carries verify, agent and connect); the first paint after an install finished shows "Start a session" alone (`justInstalled`), an error on an installed model is the headline with a grey dot, and Remove is quiet everywhere but red in its own question, never the accent. ⚠️ The window polls the GET once a second WHILE OPEN and never otherwise (no SSE event): `closeLocalModelWindow()` clears the timer, a background tab asks nothing, an unchanged answer repaints nothing, and a tick that moved only the download reLine truncated
**Run a model on this Mac** (`local-model-ui.js`; welcome button `#welcomeLocalModelBtn`, chooser window `#localModelModal`, App Settings > Models row `#localModelSettings`; server rules in the root CLAUDE.md, Local model): ONE window does everything (choose, confirm, progress, paused, failed, installed, no recommendation); the button and the Settings row only open it. Which screen shows follows `install.state` of `GET /api/local-model`; the page itself holds only the selection and whether the person is on the chooser or the confirm screen. ⚠️ The window is ONE frame for the whole flow: a fixed height on desktop (`.modal-content.local-model-window`), App Settings' boxed close button, the body scrolling between the header and a foot of its own (`#localModelWindowFoot`): every screen's last part (`.lm-foot`, or the `.lm-remove` question) is moved there by `_renderLocalModelWindow()`, so a button never moves away from under the pointer and is never clipped; a test that looks for a button looks in the frame, not the body. The short screens are centred in that frame on desktop and sit above the buttons on a phone (`data-screen` on the body). ⚠️ The memory bar (`.lm-bar`, `role="meter"`) is the one strong element: filled to the selected row's `budgetShare`, the model at its PEAK (the legend says so, and the installed line uses the same words beside what the model takes now, so the two figures are never read as a contradiction), the memory map (`Candidate.memory`: the model, its context and the brief peaks side by side, each named in the legend, on a track of `--text-muted` at about a third) in the feature's own colours (`--lm-*`: white for the feature, turned over to dark ink on the light skins, blue the context, green good, amber tight, orange slow, rose the brief peaks, deeper on the light skins), never `--accent`, which the owner rejected as too much blue (about 3:1 against the window, so the free part can be seen; a border token is 1.5:1 and the inputs' `--bg-input` is the window's own surface on the dark skins), 10px, no gradient or glow, and its width transition is the only motion (off under `prefers-reduced-motion`); a repaint starts each bar at the last width of its kind, so keep `_settleLocalModelBars()` after every body write. The figure beside it is an estimate and says "about" in whole GB (`localModelWholeGb()` on the server's text); only the download figure keeps tabular figures. ⚠️ The rows are the server's pick first, then the other models by download size, largest first (`localModelCandidates()`): the server's alternatives are its ranking, which no column shows. ⚠️ The download bar means ONE thing, the model download: at the start while the runtime is fetched, gone once the model is in (the step list carries verify, agent and connect); the first paint after an install finished shows "Start a session" alone (`justInstalled`), an error on an installed model is the headline with a grey dot, and Remove is quiet everywhere but red in its own question, never the accent. ⚠️ The window polls the GET once a second WHILE OPEN and never otherwise (no SSE event): `closeLocalModelWindow()` clears the timer, a background tab asks nothing, an unchanged answer repaints nothing, and a tick that moved only the download replaces `.lm-meter` alone, so a button is never swapped out under a click. A read that was already under way when a verb answered is dropped (`_localModelVerbs`), or the old state flashes back. ⚠️ The consent is bound to the size shown: the install request carries the row's `installBytes` as `acknowledgedBytes`, and a 409 `size-changed` shows `expectedGb` and sends `expectedBytes` next; never compute a total here. ⚠️ A button exists only for an action in `install.actions`, and after an error exactly one helpful action (`error.next`, already adapted by the server to the state: `start` on an installed model, `retry` on a job) plus a quiet Remove; an error's text is split after its first sentence (`localModelSentences()`, which knows the ideographic full stop) and only that sentence is the headline. A refusal the server names (a 400, a full disk) carries `data.message` like a 409 and is printed from it, never from the English `error` text. ⚠️ Every dynamic string goes in through `textContent` (model, coding agent and chip names, an error's detail, session names, the folder and the install command are someone else's text); the window body, the button and the row carry `data-i18n-skip` and the module translates its own sentences at render time, so a language or display-name change repaints from the module (the `<html lang>` observer). ⚠️ Labels and sentences come from the payload (`{ text, vars }` through `localModelT`); the module holds no list of models or coding agents, compares no CLI id (the agent select's values are positions in the server's list, the id travels only as data into the install request and `selectCustomModelEntry`) and never contains the word "RecoLine truncated
**Home-screen session order** (`CodemanSessionOrder` in constants.js, pure): BOTH home screens (phone overview, desktop rail) must order rows through this ONE comparator. Rank `needs` → `error` → `waiting` → `working` → `idle` → `done`. ⚠️ The tiebreak flips: states a session is still IN sort oldest-first, states it has STOPPED sort newest-first. ⚠️ The running group keys off `lastSubmitAt`, never `lastActivityAt` (a working pane repaints constantly). ⚠️ A 0 stamp means unknown and sorts last within its state. Final tiebreak is `orderIndex`, so the list never shuffles. The tab strip itself is NOT sorted by this. Test: `test/session-overview-order.test.ts`. → [architecture-invariants#home-screen-session-order](../../docs/architecture-invariants.md#home-screen-session-order)
+8 -4
View File
@@ -3181,9 +3181,11 @@ class CodemanApp {
? 'OMP'
: mode === 'copilot'
? 'GitHub Copilot'
: mode === 'opencode'
? 'OpenCode'
: 'Claude';
: mode === 'jcode'
? 'jcode'
: mode === 'opencode'
? 'OpenCode'
: 'Claude';
}
async toggleResponseViewer() {
@@ -11018,7 +11020,9 @@ class CodemanApp {
? 'Kill Tmux & OMP'
: session.mode === 'copilot'
? 'Kill Tmux & GitHub Copilot'
: 'Kill Tmux & Claude Code';
: session.mode === 'jcode'
? 'Kill Tmux & jcode'
: 'Kill Tmux & Claude Code';
}
document.getElementById('closeConfirmModal').classList.add('active');
+4 -42
View File
@@ -276,6 +276,7 @@
'Run DeepSeek': '运行 DeepSeek',
'Run OMP': '运行 OMP',
'Run GitHub Copilot': '运行 GitHub Copilot',
'Run jcode': '运行 jcode',
'Run Shell': '运行 Shell',
'More tools': '更多工具',
'Select AI backend': '选择 AI 后端',
@@ -1434,36 +1435,6 @@
'Installing {model}': '正在安装 {model}',
'The install of {model} is paused': '{model} 的安装已暂停',
'The install of {model} stopped': '{model} 的安装已中止',
// A switch to a model that is on this Mac already: nothing is downloaded.
'Switching to {model}': '正在切换到 {model}',
'The switch to {model} is paused': '切换到 {model} 的操作已暂停',
'The switch to {model} stopped': '切换到 {model} 的操作已中止',
'The switch waits until you continue.': '切换会等你点击继续后再进行。',
'You can close this window. The switch continues while Codeman is running.':
'可以关闭此窗口。只要 {name} 在运行,切换就会继续。',
// The models on this Mac: the list of the installed screen, its states and its two actions.
'Models on this Mac': '此 Mac 上的模型',
'On disk': '占用磁盘',
'{context} tokens': '{context} 个令牌',
'In use': '使用中',
Ready: '就绪',
Changed: '已变化',
Missing: '已缺失',
'Not checked': '未检查',
'On this Mac': '已在此 Mac 上',
Use: '使用',
'Use {model}': '使用 {model}',
'Use this model': '使用此模型',
'Delete {model}': '删除 {model}',
'Delete {model} from this Mac?': '要从此 Mac 删除 {model} 吗?',
'This frees {size} GB.': '这会释放 {size} GB。',
'Downloaded models use {size} GB on this Mac.': '已下载的模型在此 Mac 上占用 {size} GB。',
'Press Start to delete a single model.': '点击“启动”后才能删除单个模型。',
'Add a model': '添加模型',
'Delete the downloaded models from this Mac?': '要从此 Mac 删除已下载的模型吗?',
'Models from an earlier install still use {size} GB on this Mac.':
'之前安装留下的模型仍在此 Mac 上占用 {size} GB。',
'Delete them': '删除它们',
'{model} is running': '{model} 正在运行',
'{model} is installed and stopped': '{model} 已安装,当前已停止',
'Checking this Mac': '正在检查此 Mac',
@@ -1608,13 +1579,9 @@
'Codeman could not save the connection to the model.': '{name} 无法保存与模型的连接。',
'The model on this Mac changed since it was installed. Remove it and install it again.':
'此 Mac 上的模型在安装后发生了变化。请移除后重新安装。',
'The model on this Mac is being changed and is not ready. Finish or undo that in the local model window first.':
'此 Mac 上的模型正在更换,尚未就绪。请先在本地模型窗口中完成或撤销这一操作。',
'The install stopped on an unexpected error.': '安装因意外错误而中止。',
'An install is already running.': '已有一个安装正在进行。',
'This model is already in use on this Mac.': '此 Mac 已在使用这个模型。',
'{model} is the model in use. Switch to another model first, or remove the local model.':
'{model} 是正在使用的模型。请先切换到其他模型,或移除本地模型。',
'A model is already installed on this Mac. Remove it first.': '此 Mac 上已安装了一个模型。请先移除它。',
'The download is {size} GB now. Check the sizes and confirm again.':
'现在的下载大小为 {size} GB。请核对大小后再次确认。',
'Sessions are still using this model: {sessions}. Close them first.':
@@ -1624,13 +1591,8 @@
'目前不为此 Mac 提供这一上下文大小下的该模型。',
'This coding agent is not offered for this model.': '该模型不提供这个编程智能体。',
'{cli} is not installed and cannot be installed from here.': '{cli} 尚未安装,也无法从这里安装。',
'Only {model} is in use on this Mac. Switch models in the local model window first.':
'此 Mac 目前只使用 {model}。请先在本地模型窗口中切换模型。',
'This model is not one of the checked models on this Mac.': '该模型不在此 Mac 上已检查的模型之列。',
'This model is not on this Mac.': '此 Mac 上没有这个模型。',
'{cli} is not installed on this Mac. Install this model again to choose a coding agent.':
'此 Mac 上没有安装 {cli}。请重新安装此模型,以便选择编程智能体。',
'{model} could not be deleted from this Mac.': '无法从此 Mac 删除 {model}。',
'Only {model} is installed on this Mac. Remove it and install another model first.':
'此 Mac 上只安装了 {model}。请先移除它,再安装其他模型。',
// What the endpoint routes answer for the record of the model on this Mac (shown as a toast).
'This endpoint belongs to the model Codeman runs on this Mac. Change or remove it in the local model window.':
'此端点属于 {name} 在此 Mac 上运行的模型。请在本地模型窗口中更改或移除它。',
+3 -1
View File
@@ -1177,6 +1177,7 @@
<option value="deepseek">DeepSeek</option>
<option value="omp">OMP</option>
<option value="copilot">GitHub Copilot</option>
<option value="jcode">jcode</option>
</select>
</div>
<div class="form-row"><label>Working Directory</label><input type="text" id="schWorkingDir" placeholder="/absolute/path"></div>
@@ -3892,6 +3893,7 @@
<option value="deepseek" data-cli="deepseek">DeepSeek</option>
<option value="omp" data-cli="omp">OMP</option>
<option value="copilot" data-cli="copilot">GitHub Copilot</option>
<option value="jcode" data-cli="jcode">jcode</option>
<option value="shell">Shell (no agent)</option>
</select>
<span class="form-hint">Which CLI to point the Run button at once the clone finishes. Changeable any time from the Run dropdown.</span>
@@ -4070,7 +4072,7 @@
<div class="form-row docker-create-only">
<label>Image</label>
<input type="text" id="dockerImage" placeholder="codeman/agent:base" autocomplete="off" autocapitalize="off" spellcheck="false">
<span class="form-hint">Build it once with <code>node scripts/build-agent-image.mjs</code>. Contains node + claude/opencode/codex/gemini/agy/pi/grok/dsh/omp/copilot + tmux.</span>
<span class="form-hint">Build it once with <code>node scripts/build-agent-image.mjs</code>. Contains node + claude/opencode/codex/gemini/agy/pi/grok/dsh/omp/copilot/jcode + tmux.</span>
</div>
<div class="form-row docker-create-only">
<label>Network</label>
+19 -358
View File
@@ -7,12 +7,10 @@
* drives the install verbs:
*
* POST /api/local-model/install { modelId, cli, context, acknowledgedBytes }
* POST /api/local-model/use { modelId }
* POST /api/local-model/cancel
* POST /api/local-model/resume { newPort? }
* POST /api/local-model/runtime { action: 'start' | 'stop', newPort? }
* DELETE /api/local-model { removeModel }
* DELETE /api/local-model/models { modelId }
*
* ## Three surfaces, one window
*
@@ -27,17 +25,14 @@
* | Screen | When |
* |-------------|-------------------------------------------------------------------|
* | `choose` | idle: the memory bar, the models that fit as a radio group, the |
* | | coding agent, "Install on this Mac". Also from an installed |
* | | model ("Add a model"): a row that is on this Mac already says so |
* | | and is switched to without a download |
* | | coding agent, "Install on this Mac" |
* | `confirm` | after that button: the download as a table with its total, where |
* | | it goes |
* | `progress` | installing: the download bar, the steps, Cancel |
* | `paused` | paused (Cancel or a server restart): Continue or Remove |
* | `failed` | failed: the server's sentence and the ONE action that helps |
* | `installed` | installed: running or stopped, the models on this Mac with their |
* | | size on disk (Use, Delete), Add a model, Start a session, |
* | | Stop/Start, Remove (right after an install: Start a session alone) |
* | `installed` | installed: running or stopped, Start a session, Stop/Start, Remove |
* | | (right after an install: Start a session alone) |
* | `none` | idle and nothing fits: the hardware line, the reason, Close |
*
* The memory bar is the one strong element: the memory this Mac can give a model, filled to
@@ -54,13 +49,6 @@
* size, largest first, which is a column the reader can see. The server's own order of the
* alternatives is its ranking (what it would offer next), not a reading order.
*
* The models on this Mac (`install.library`) are a list of their own, on the installed screen
* and on a paused or failed one that has a model to go back to: the model in use first, each
* with its size on disk and the two things a row can offer, "Use" (a switch, nothing is
* downloaded) and "Delete" (asked once, in the row). Under it the disk use of all downloaded
* models (`install.store`) and "Add a model". A row has a button only for an action the server
* lists for THAT row (`row.actions`).
*
* ## Polling
*
* The window asks the GET once a second WHILE IT IS OPEN, and never otherwise (there is no SSE
@@ -112,9 +100,6 @@ const LOCAL_MODEL_MAX_AGE_MS = 60000;
/** How often the open window asks where the install stands. */
const LOCAL_MODEL_POLL_MS = 1000;
/** What `confirmDelete` holds while the question is about everything an earlier Remove kept. No model id has a space. */
const LOCAL_MODEL_STORE_KEY = 'the store';
/** The tag kinds of the server's pick (`CandidateTagKind`): the ones drawn in the accent colour. */
const LOCAL_MODEL_ACCENT_TAGS = ['tested', 'best', 'best-tight'];
@@ -217,29 +202,6 @@ function localModelCandidates(status) {
);
}
/**
* Whether the job of `install` is a switch to a model that is on this Mac already: its steps
* hold no download. The steps are the server's; nothing is compared with a model or an agent.
*/
function localModelIsSwitch(install) {
const steps = Array.isArray(install.steps) ? install.steps : [];
return steps.length > 0 && !steps.includes('model');
}
/** The rows of `install.library`: the models on this Mac. A payload without the list has none. */
function localModelLibrary(install) {
return (Array.isArray(install.library) ? install.library : []).filter((row) => row && typeof row.id === 'string');
}
/** What a library row's state is called, and the tag class that colours it. */
function localModelLibraryTag(row) {
if (row.active === true) return { text: localModelT('In use'), kind: 'inuse' };
if (row.status === 'ready') return { text: localModelT('Ready'), kind: 'ready' };
if (row.status === 'changed') return { text: localModelT('Changed'), kind: 'changed' };
if (row.status === 'missing') return { text: localModelT('Missing'), kind: 'missing' };
return { text: localModelT('Not checked'), kind: 'unverified' };
}
/**
* The coding agents a row can run with. A payload without the list means the one agent of the
* row's flat fields.
@@ -554,11 +516,6 @@ Object.assign(CodemanApp.prototype, {
/** The Remove question is open, and whether the downloaded model goes too. */
confirmRemove: false,
removeModel: true,
/**
* The Delete question of one row is open: that row's model id, or `LOCAL_MODEL_STORE_KEY`
* for the downloaded models an earlier Remove kept.
*/
confirmDelete: null,
/**
* The install finished while this window was open: the screen that ends it shows
* "Start a session" alone. Stop and Remove belong to the window opened later.
@@ -586,10 +543,9 @@ Object.assign(CodemanApp.prototype, {
const install = localModelInstall(this._localModelStatus);
const selection = this._localModelSelection();
if (install.state === 'installing') return 'progress';
const canInstall = install.actions.includes('install') && !!selection;
// The chooser is also where a second model is added to an installed one.
if (canInstall && (ui.view === 'choose' || ui.view === 'confirm')) return ui.view;
if (install.state === 'installed') return 'installed';
const canInstall = install.actions.includes('install') && !!selection;
if (canInstall && (ui.view === 'choose' || ui.view === 'confirm')) return ui.view;
if (install.state === 'paused') return 'paused';
if (install.state === 'failed') return 'failed';
return selection ? 'choose' : 'none';
@@ -616,7 +572,6 @@ Object.assign(CodemanApp.prototype, {
notice: null,
expected: null,
confirmRemove: false,
confirmDelete: null,
justInstalled: false,
});
this._forgetLocalModelPaint();
@@ -735,10 +690,10 @@ Object.assign(CodemanApp.prototype, {
this.renderLocalModel();
},
/** Back to the chooser (from the confirm screen, to pick another model after a stop, or to add one). */
/** Back to the chooser (from the confirm screen, or to pick another model after a stop). */
chooseLocalModel(modelId) {
const ui = this._localModelWindowState();
Object.assign(ui, { view: 'choose', notice: null, expected: null, confirmRemove: false, confirmDelete: null });
Object.assign(ui, { view: 'choose', notice: null, expected: null, confirmRemove: false });
if (typeof modelId === 'string') {
ui.modelId = modelId;
ui.cli = null;
@@ -794,57 +749,6 @@ Object.assign(CodemanApp.prototype, {
this.renderLocalModel();
},
/**
* "Use": switch to a model that is on this Mac already. Nothing is downloaded, so there is
* no confirm screen: the server loads and checks the model again and connects it.
*/
async useLocalModel(modelId) {
const ui = this._localModelWindowState();
ui.confirmDelete = null;
const result = await this._localModelVerb('POST', '/api/local-model/use', { modelId });
// Started, or refused with the server's sentence: either way the state's own screen says it.
if (result.ok || result.conflict) ui.view = 'auto';
this.renderLocalModel();
},
/** "Delete" of one row: first the question in the row, then (with `confirmed`) the delete. */
async deleteLocalModelEntry(modelId, { confirmed = false } = {}) {
const ui = this._localModelWindowState();
if (!confirmed) {
Object.assign(ui, { confirmDelete: modelId, confirmRemove: false, notice: null });
this.renderLocalModel();
// On the safe answer, whatever had the focus (a click does not move it in every browser).
this._focusLocalModelWindow('delete-no');
return;
}
await this._localModelVerb('DELETE', '/api/local-model/models', { modelId });
ui.confirmDelete = null;
this.renderLocalModel();
},
/** "Delete them": the downloaded models an earlier Remove kept, while nothing is installed. */
async deleteLocalModelStore({ confirmed = false } = {}) {
const ui = this._localModelWindowState();
if (!confirmed) {
Object.assign(ui, { confirmDelete: LOCAL_MODEL_STORE_KEY, notice: null });
this.renderLocalModel();
this._focusLocalModelWindow('delete-no');
return;
}
await this._localModelVerb('DELETE', '/api/local-model', { removeModel: true });
ui.confirmDelete = null;
this.renderLocalModel();
},
/** Leave a Delete question without deleting. The focus goes back to the button that asked. */
keepLocalModelEntry() {
const ui = this._localModelWindowState();
const asked = ui.confirmDelete;
Object.assign(ui, { confirmDelete: null, notice: null });
this.renderLocalModel();
if (asked) this._focusLocalModelWindow(asked === LOCAL_MODEL_STORE_KEY ? 'store-delete' : `delete:${asked}`);
},
cancelLocalModelInstall() {
return this._localModelVerb('POST', '/api/local-model/cancel');
},
@@ -863,7 +767,7 @@ Object.assign(CodemanApp.prototype, {
async removeLocalModel({ confirmed = false } = {}) {
const ui = this._localModelWindowState();
if (!confirmed) {
Object.assign(ui, { confirmRemove: true, confirmDelete: null, notice: null });
Object.assign(ui, { confirmRemove: true, notice: null });
this.renderLocalModel();
return;
}
@@ -925,20 +829,9 @@ Object.assign(CodemanApp.prototype, {
const status = this._localModelStatus;
const install = localModelInstall(status);
const model = install.model ? String(install.model.label || install.model.id || '') : '';
const switching = localModelIsSwitch(install);
if (install.state === 'installing') {
return switching ? localModelT('Switching to {model}', { model }) : localModelT('Installing {model}', { model });
}
if (install.state === 'paused') {
return switching
? localModelT('The switch to {model} is paused', { model })
: localModelT('The install of {model} is paused', { model });
}
if (install.state === 'failed') {
return switching
? localModelT('The switch to {model} stopped', { model })
: localModelT('The install of {model} stopped', { model });
}
if (install.state === 'installing') return localModelT('Installing {model}', { model });
if (install.state === 'paused') return localModelT('The install of {model} is paused', { model });
if (install.state === 'failed') return localModelT('The install of {model} stopped', { model });
if (install.state === 'installed') {
if (install.running) return localModelT('{model} is running', { model });
return localModelT('{model} is installed and stopped', { model });
@@ -1024,8 +917,6 @@ Object.assign(CodemanApp.prototype, {
foot && tail && tail.classList && (tail.classList.contains('lm-foot') || tail.classList.contains('lm-remove'));
body.dataset.screen = screen;
body.replaceChildren(...(actions ? parts.slice(0, -1) : parts));
// A screen that carries the list of models starts at the top and scrolls (see styles.css).
body.dataset.long = body.querySelector('.lm-library') ? 'true' : 'false';
if (foot) foot.replaceChildren(...(actions ? [tail] : []));
body.scrollTop = scrolled;
this._settleLocalModelBars(body);
@@ -1140,12 +1031,6 @@ Object.assign(CodemanApp.prototype, {
tag.classList.add(`lm-tag-${candidate.tag.kind}`);
tagSlot.appendChild(tag);
}
// Already on this Mac: choosing it switches to it, and nothing is downloaded.
if (candidate.library === 'active') {
tagSlot.appendChild(localModelEl('span', 'lm-tag lm-tag-inuse', localModelT('In use')));
} else if (candidate.library === 'ready') {
tagSlot.appendChild(localModelEl('span', 'lm-tag lm-tag-ready', localModelT('On this Mac')));
}
row.appendChild(tagSlot);
const speed = localModelEl('span', 'lm-model-speed', localModelSpeedText(candidate));
const gauge = localModelEl(
@@ -1268,10 +1153,6 @@ Object.assign(CodemanApp.prototype, {
if (hints.length) runs.appendChild(localModelEl('span', 'lm-runs-hint', hints.join(' ')));
parts.push(runs);
// Nothing is installed, and an earlier Remove kept its downloads: what they take, and the way to free it.
const leftovers = this._localModelStoreEl(install);
if (leftovers) parts.push(leftovers);
const notice = this._localModelNoticeEl();
if (notice) parts.push(notice);
@@ -1279,25 +1160,13 @@ Object.assign(CodemanApp.prototype, {
const promise = localModelT('No account and no API key. The model runs only on this Mac.');
foot.appendChild(localModelEl('p', 'lm-foot-note', promise));
const buttons = localModelEl('div', 'lm-buttons');
// Reached from a paused or stopped install, or from an installed model: the way back to it.
// Reached from a paused or stopped install: the way back to it.
if (install.state !== 'idle') {
buttons.appendChild(
localModelButton(localModelT('Back'), { key: 'back', onClick: () => this.leaveLocalModelChooser() })
);
}
if (candidate.library === 'active') {
// The model that runs now: nothing to do with it here.
buttons.appendChild(localModelButton(localModelT('In use'), { key: 'primary', primary: true, disabled: true }));
} else if (candidate.library === 'ready' && install.actions.includes('use')) {
buttons.appendChild(
localModelButton(localModelT('Use this model'), {
key: 'primary',
primary: true,
disabled: ui.busy,
onClick: () => void this.useLocalModel(candidate.modelId),
})
);
} else if (install.actions.includes('install')) {
if (install.actions.includes('install')) {
buttons.appendChild(
localModelButton(localModelT('Install on this Mac'), {
key: 'primary',
@@ -1312,189 +1181,6 @@ Object.assign(CodemanApp.prototype, {
return parts;
},
/**
* The models on this Mac: one row per model of `install.library` with its name, the context
* it was checked with, its state, its size on disk and what the server offers for it. A row
* whose Delete was pressed is followed by the question, once, in place. `null` when there is
* no row, or (with `onlyWithActions`, for a paused or failed job) no row offers anything.
*/
_localModelLibraryEl(install, { onlyWithActions = false } = {}) {
const ui = this._localModelWindowState();
const rows = localModelLibrary(install);
const offers = (row, action) => Array.isArray(row.actions) && row.actions.includes(action);
if (!rows.length) return null;
if (onlyWithActions && !rows.some((row) => offers(row, 'use') || offers(row, 'delete'))) return null;
const section = localModelEl('div', 'lm-library');
const list = localModelEl('div', 'lm-lib-list');
list.setAttribute('role', 'list');
list.setAttribute('aria-labelledby', 'localModelLibraryLabel');
const caption = localModelEl('div', 'lm-lib-caption');
const label = localModelEl('span', 'lm-lib-caption-label', localModelT('Models on this Mac'));
label.id = 'localModelLibraryLabel';
caption.appendChild(label);
caption.appendChild(localModelEl('span', 'lm-lib-caption-size', localModelT('On disk')));
list.appendChild(caption);
for (const row of rows) {
const name = String(row.label || row.id);
const item = localModelEl('div', row.active === true ? 'lm-lib-row lm-lib-row-on' : 'lm-lib-row');
item.setAttribute('role', 'listitem');
const what = localModelEl('div', 'lm-lib-name');
what.appendChild(localModelEl('span', 'lm-lib-label', name));
if (Number.isFinite(row.context)) {
what.appendChild(
localModelEl(
'span',
'lm-lib-meta',
localModelT('{context} tokens', { context: localModelCount(row.context) })
)
);
}
item.appendChild(what);
const tag = localModelLibraryTag(row);
const tagSlot = localModelEl('span', 'lm-lib-tag');
tagSlot.appendChild(localModelEl('span', `lm-tag lm-tag-${tag.kind}`, tag.text));
item.appendChild(tagSlot);
item.appendChild(localModelEl('span', 'lm-lib-size', row.sizeGb ? localModelGb(row.sizeGb) : ''));
const asking = ui.confirmDelete === row.id && offers(row, 'delete');
const actions = localModelEl('span', 'lm-lib-actions');
// While the row's question is open its buttons keep their place and are out of reach,
// so the column of sizes does not move when the question opens.
if (asking) {
actions.classList.add('lm-lib-actions-away');
actions.setAttribute('aria-hidden', 'true');
}
if (offers(row, 'use')) {
const use = localModelButton(localModelT('Use'), {
key: `use:${row.id}`,
disabled: ui.busy || asking,
onClick: () => void this.useLocalModel(row.id),
});
use.classList.add('lm-btn-row');
use.setAttribute('aria-label', localModelT('Use {model}', { model: name }));
actions.appendChild(use);
}
if (offers(row, 'delete')) {
const remove = localModelButton(localModelT('Delete'), {
key: `delete:${row.id}`,
disabled: ui.busy || asking,
onClick: () => void this.deleteLocalModelEntry(row.id),
});
remove.classList.add('lm-btn-row');
remove.setAttribute('aria-label', localModelT('Delete {model}', { model: name }));
actions.appendChild(remove);
}
item.appendChild(actions);
list.appendChild(item);
if (asking) {
list.appendChild(
this._localModelAskEl({
question: localModelT('Delete {model} from this Mac?', { model: name }),
detail: row.sizeGb ? localModelT('This frees {size} GB.', { size: row.sizeGb }) : '',
onYes: () => void this.deleteLocalModelEntry(row.id, { confirmed: true }),
})
);
}
}
section.appendChild(list);
const foot = localModelEl('div', 'lm-lib-foot');
const notes = localModelEl('div', 'lm-lib-notes');
const store = install.store;
if (store && Number(store.bytes) > 0 && store.gb) {
notes.appendChild(
localModelEl('p', 'lm-aside', localModelT('Downloaded models use {size} GB on this Mac.', { size: store.gb }))
);
}
// Only the runtime deletes a model: with it stopped, the rows offer none, and this says why.
if (install.state === 'installed' && install.running !== true && rows.length > 1) {
notes.appendChild(localModelEl('p', 'lm-aside', localModelT('Press Start to delete a single model.')));
}
if (notes.childElementCount) foot.appendChild(notes);
if (install.state === 'installed' && install.actions.includes('install') && this._localModelSelection()) {
const add = localModelButton(localModelT('Add a model'), {
key: 'add',
disabled: ui.busy,
onClick: () => this.chooseLocalModel(),
});
add.classList.add('lm-btn-row');
foot.appendChild(add);
}
if (foot.childElementCount) section.appendChild(foot);
return section;
},
/** A Delete question in place: the question, what it frees, Cancel and the red Delete. */
_localModelAskEl({ question, detail, onYes }) {
const ui = this._localModelWindowState();
const ask = localModelEl('div', 'lm-lib-ask');
ask.setAttribute('role', 'group');
const text = localModelEl('div', 'lm-lib-ask-text');
text.appendChild(localModelEl('p', 'lm-lead', question));
if (detail) text.appendChild(localModelEl('p', 'lm-aside', detail));
ask.appendChild(text);
const buttons = localModelEl('div', 'lm-lib-actions');
const no = localModelButton(localModelT('Cancel'), {
key: 'delete-no',
disabled: ui.busy,
onClick: () => this.keepLocalModelEntry(),
});
no.classList.add('lm-btn-row');
const yes = localModelButton(localModelT('Delete'), {
key: 'delete-yes',
danger: true,
disabled: ui.busy,
onClick: onYes,
});
yes.classList.add('lm-btn-row');
buttons.appendChild(no);
buttons.appendChild(yes);
ask.appendChild(buttons);
return ask;
},
/**
* Nothing is installed and the model store still holds downloads (an earlier Remove kept
* them): what they take on disk and "Delete them", asked once in place. `null` unless the
* server offers that removal.
*/
_localModelStoreEl(install) {
const ui = this._localModelWindowState();
const store = install.store;
if (install.state !== 'idle' || !install.actions.includes('remove')) return null;
if (!store || !(Number(store.bytes) > 0) || !store.gb) return null;
if (ui.confirmDelete === LOCAL_MODEL_STORE_KEY) {
const box = localModelEl('div', 'lm-leftovers');
box.appendChild(
this._localModelAskEl({
question: localModelT('Delete the downloaded models from this Mac?'),
detail: localModelT('This frees {size} GB.', { size: store.gb }),
onYes: () => void this.deleteLocalModelStore({ confirmed: true }),
})
);
return box;
}
const box = localModelEl('div', 'lm-leftovers lm-lib-foot');
box.appendChild(
localModelEl(
'p',
'lm-aside',
localModelT('Models from an earlier install still use {size} GB on this Mac.', { size: store.gb })
)
);
const remove = localModelButton(localModelT('Delete them'), {
key: 'store-delete',
disabled: ui.busy,
onClick: () => void this.deleteLocalModelStore(),
});
remove.classList.add('lm-btn-row');
box.appendChild(remove);
return box;
},
/** Arrow keys, Home and End move the selection of the radio group, and the focus with it. */
_onLocalModelListKey(event) {
const selection = this._localModelSelection();
@@ -1711,18 +1397,9 @@ Object.assign(CodemanApp.prototype, {
const paused = screen === 'paused';
const model = install.model ? String(install.model.label || install.model.id || '') : '';
const parts = [];
// A switch to a model that is on this Mac already downloads nothing, and says so in its name.
const switching = localModelIsSwitch(install);
let title;
if (switching) {
title = paused
? localModelT('The switch to {model} is paused', { model })
: localModelT('Switching to {model}', { model });
} else {
title = paused
? localModelT('The install of {model} is paused', { model })
: localModelT('Installing {model}', { model });
}
const title = paused
? localModelT('The install of {model} is paused', { model })
: localModelT('Installing {model}', { model });
parts.push(localModelEl('p', 'lm-title', title));
// The download bar takes the place of the memory bar, for as long as something downloads.
@@ -1737,26 +1414,19 @@ Object.assign(CodemanApp.prototype, {
return parts;
}
// What waits or goes on is the download while there is one, the install (or the switch) after it.
// What waits or goes on is the download while there is one, the install after it.
const downloading = !!meter;
let note;
if (paused && switching) {
note = localModelT('The switch waits until you continue.');
} else if (paused) {
if (paused) {
note = downloading
? localModelT('The download waits until you continue.')
: localModelT('The install waits until you continue.');
} else if (switching) {
note = localModelT('You can close this window. The switch continues while Codeman is running.');
} else {
note = downloading
? localModelT('You can close this window. The download continues while Codeman is running.')
: localModelT('You can close this window. The install continues while Codeman is running.');
}
parts.push(localModelEl('p', 'lm-aside', note));
// A paused job that has a checked model beside it: the way back to that model.
const library = paused ? this._localModelLibraryEl(install, { onlyWithActions: true }) : null;
if (library) parts.push(library);
const notice = this._localModelNoticeEl();
if (notice) parts.push(notice);
@@ -1878,9 +1548,6 @@ Object.assign(CodemanApp.prototype, {
parts.push(this._localModelRemoveEl());
return parts;
}
// A checked model beside the one that stopped: the way back to it.
const library = this._localModelLibraryEl(install, { onlyWithActions: true });
if (library) parts.push(library);
const notice = this._localModelNoticeEl();
if (notice) parts.push(notice);
@@ -1974,17 +1641,13 @@ Object.assign(CodemanApp.prototype, {
const tools = this._localModelDeveloperToolsEl();
if (tools) parts.push(tools);
const notice = this._localModelNoticeEl();
if (notice) parts.push(notice);
const can = (action) => install.actions.includes(action);
const next = error ? this._localModelNextButton(install, error) : null;
// The screen that ends an install has one button. Stop and Remove are for the window
// opened later (or for this one once anything else was said in it).
const ending = ui.justInstalled === true && can('start-session') && !error && !notice;
// Everything that is on this Mac, what it takes on disk, and the way to another model.
// Not on the screen that ends an install: that one says one thing.
const library = ending ? null : this._localModelLibraryEl(install);
if (library) parts.push(library);
if (notice) parts.push(notice);
const foot = localModelEl('div', 'lm-foot');
const buttons = localModelEl('div', 'lm-buttons');
if (!ending && can('remove') && error?.next !== 'remove') {
@@ -2040,8 +1703,6 @@ Object.assign(CodemanApp.prototype, {
if (hw) parts.push(hw);
const reason = localModelMessage(status.recommendation.headline);
if (reason) parts.push(localModelEl('p', 'lm-reason', reason));
const leftovers = this._localModelStoreEl(localModelInstall(status));
if (leftovers) parts.push(leftovers);
const notice = this._localModelNoticeEl();
if (notice) parts.push(notice);
const foot = localModelEl('div', 'lm-foot');
+1
View File
@@ -59,6 +59,7 @@ const MOBILE_OVERVIEW_RUN_MODES = [
{ mode: 'deepseek', label: 'DeepSeek', short: 'DeepSeek' },
{ mode: 'omp', label: 'OMP', short: 'OMP' },
{ mode: 'copilot', label: 'GitHub Copilot', short: 'GitHub Copilot' },
{ mode: 'jcode', label: 'jcode', short: 'jcode' },
{ mode: 'shell', label: 'Terminal / Shell', short: 'Shell' },
];
+20 -60
View File
@@ -1279,6 +1279,20 @@ html.mobile-init .file-browser-panel {
border-color: rgba(163, 113, 247, 0.5) !important;
}
/* jcode mode colors on mobile. Same `!important` rationale as the pi/grok/deepseek/omp blocks above. */
.btn-toolbar.btn-run.mode-jcode,
.btn-toolbar.btn-run-gear.mode-jcode {
background: #78350f !important;
border-color: rgba(245, 158, 11, 0.3) !important;
color: #fffbeb !important;
}
.btn-toolbar.btn-run.mode-jcode:active,
.btn-toolbar.btn-run-gear.mode-jcode:active {
background: #b45309 !important;
border-color: rgba(245, 158, 11, 0.5) !important;
}
/* Run mode dropdown menu — positioned above toolbar on mobile */
.run-mode-menu {
bottom: 100%;
@@ -1887,11 +1901,6 @@ html.mobile-init .file-browser-panel {
justify-content: flex-end;
}
/* A screen with the list of models scrolls from its top. */
.local-model-window > .lm-body[data-long='true'] {
justify-content: flex-start;
}
/* Ralph wizard responsive */
.modal-wizard {
display: flex;
@@ -3442,6 +3451,12 @@ html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="cat
color: #ffffff;
}
html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="catppuccin-latte"], [data-skin="rose-pine-dawn"]) :is(.btn-toolbar.btn-run.mode-jcode, .btn-toolbar.btn-run-gear.mode-jcode) {
background: linear-gradient(135deg, #92400e, #d97706);
border-color: #78350f;
color: #ffffff;
}
html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="catppuccin-latte"], [data-skin="rose-pine-dawn"]) .btn-toolbar.btn-run-gear {
border-left-color: var(--control-border-hover) !important;
}
@@ -4344,61 +4359,6 @@ html[data-session-list="sidebar"] .session-sidebar .session-tab .tab-close {
grid-column: 3;
}
/* The models on this Mac: the name and its size, then the state and what can be done. */
.lm-lib-list {
grid-template-columns: minmax(0, 1fr) auto;
column-gap: 0.7rem;
}
.lm-lib-caption-label {
grid-column: 1;
}
.lm-lib-caption-size {
grid-column: 2;
}
.lm-lib-row {
row-gap: 0.15rem;
padding-top: 0.45rem;
padding-bottom: 0.45rem;
}
.lm-lib-name {
grid-row: 1;
grid-column: 1;
padding: 0;
}
.lm-lib-size {
grid-row: 1;
grid-column: 2;
}
.lm-lib-tag {
grid-row: 2;
grid-column: 1;
}
.lm-lib-actions {
grid-row: 2;
grid-column: 2;
}
.lm-lib-ask {
margin-inline: 0.7rem;
}
.lm-lib-foot {
flex-wrap: wrap;
}
/* A row's buttons keep their own size: they sit in the row, not under the thumb. */
.local-model-window .wt-btn.lm-btn.lm-btn-row {
min-height: 36px;
font-size: 0.8rem;
}
.lm-foot {
flex-direction: column;
align-items: stretch;
+1 -1
View File
@@ -559,7 +559,7 @@ Object.assign(CodemanApp.prototype, {
const caseName = this._commandPaletteCaseName(query);
const agent = this._commandPaletteAgent(caseName);
const mode = agent?.id || this.runMode || this._runMode || 'claude';
const labels = { claude: 'Claude', opencode: 'OpenCode', codex: 'Codex', gemini: 'Gemini', antigravity: 'Antigravity', pi: 'Pi', grok: 'Grok', deepseek: 'DeepSeek', omp: 'OMP', copilot: 'Copilot' };
const labels = { claude: 'Claude', opencode: 'OpenCode', codex: 'Codex', gemini: 'Gemini', antigravity: 'Antigravity', pi: 'Pi', grok: 'Grok', deepseek: 'DeepSeek', omp: 'OMP', copilot: 'Copilot', jcode: 'jcode' };
return {
id: 'new-session',
type: 'new-session',
+13
View File
@@ -89,6 +89,15 @@ const RUN_MODE_LAUNCH = {
// The multi-user clamp forces it back off for non-granted owners server-side.
buildConfig: () => ({ copilotConfig: { allowAll: true } }),
},
jcode: {
label: 'jcode',
installHint: 'jcode not found. Install with: curl -fsSL https://jcode.sh/install | bash',
// No custom endpoint recipe yet: jcode's provider lives in its shared daemon, not the pane.
supportsCustomModel: false,
// Deliberately no jcodeConfig: jcode has no approval prompts, so there is no bypass
// switch to opt into (same as pi).
buildConfig: () => null,
},
grok: {
label: 'Grok',
installHint: 'Grok CLI not found. Install with: curl -fsSL https://x.ai/cli/install.sh | bash',
@@ -2700,6 +2709,10 @@ Object.assign(CodemanApp.prototype, {
return this._runCliMode('copilot');
},
async runJcode() {
return this._runCliMode('jcode');
},
// ═══════════════════════════════════════════════════════════════
// Session Options Modal
+30 -207
View File
@@ -6023,6 +6023,24 @@ html[data-skin="og"] .welcome-primary:hover {
color: #f8faff;
}
/* jcode mode colors. Same cascade note as pi/grok/deepseek above: this base-sheet
pair only renders on the `og` skin, so jcode also carries a rule inside the nested
`html:not([data-skin="og"])` block (search `.btn-toolbar.btn-run.mode-jcode`). */
.btn-toolbar.btn-run.mode-jcode,
.btn-toolbar.btn-run-gear.mode-jcode {
background: linear-gradient(135deg, #451a03 0%, #92400e 55%, #b45309 100%);
border-color: rgba(245, 158, 11, 0.5);
color: #fffbeb;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.2), inset 0 1px 0 rgba(255, 255, 255, 0.06);
}
.btn-toolbar.btn-run.mode-jcode:hover,
.btn-toolbar.btn-run-gear.mode-jcode:hover {
background: linear-gradient(135deg, #5c2405 0%, #b45309 55%, #d97706 100%);
box-shadow: 0 0 12px rgba(245, 158, 11, 0.35), 0 2px 8px rgba(180, 83, 9, 0.25), inset 0 1px 0 rgba(255, 255, 255, 0.08);
border-color: rgba(252, 211, 77, 0.6);
color: #ffffff;
}
/* Dropdown menu */
.run-mode-menu {
display: none;
@@ -6134,7 +6152,8 @@ html[data-skin="og"] .welcome-primary:hover {
~/.codeman/clis.json, say) keeps the plain 8px dot.
Logos: @lobehub/icons-static-svg 1.95.1 (MIT, (c) LobeHub); the OMP mark is
the one omp.sh draws in its own header (oh-my-pi, MIT). The marks are their
owners' trademarks, shown only to name the CLI a row launches. */
owners' trademarks, shown only to name the CLI a row launches. jcode's own mark
is a 60 KB halftone torus, so its slot draws a plain ring in its place. */
.run-mode-dot.claude,
.run-mode-dot.gemini,
.run-mode-dot.antigravity,
@@ -6145,6 +6164,7 @@ html[data-skin="og"] .welcome-primary:hover {
.run-mode-dot.pi,
.run-mode-dot.grok,
.run-mode-dot.copilot,
.run-mode-dot.jcode,
.run-mode-dot.shell,
.run-mode-dot.web {
width: 1.15em;
@@ -6166,6 +6186,7 @@ html[data-skin="og"] .welcome-primary:hover {
.run-mode-dot.pi,
.run-mode-dot.grok,
.run-mode-dot.copilot,
.run-mode-dot.jcode,
.run-mode-dot.shell,
.run-mode-dot.web {
background-color: currentColor;
@@ -6182,6 +6203,7 @@ html[data-skin="og"] .welcome-primary:hover {
.run-mode-dot.pi { --run-mode-logo: url("data:image/svg+xml,%3Csvg fill-rule='evenodd' viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath clip-rule='evenodd' d='M1 1h16.5v11H12v5.5H6.5V23H1V1zm5.5 5.5V12H12V6.5H6.5z'/%3E%3Cpath d='M17.5 12H23v11h-5.5V12z'/%3E%3C/svg%3E"); }
.run-mode-dot.grok { --run-mode-logo: url("data:image/svg+xml,%3Csvg fill-rule='evenodd' viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M9.27 15.29l7.978-5.897c.391-.29.95-.177 1.137.272.98 2.369.542 5.215-1.41 7.169-1.951 1.954-4.667 2.382-7.149 1.406l-2.711 1.257c3.889 2.661 8.611 2.003 11.562-.953 2.341-2.344 3.066-5.539 2.388-8.42l.006.007c-.983-4.232.242-5.924 2.75-9.383.06-.082.12-.164.179-.248l-3.301 3.305v-.01L9.267 15.292M7.623 16.723c-2.792-2.67-2.31-6.801.071-9.184 1.761-1.763 4.647-2.483 7.166-1.425l2.705-1.25a7.808 7.808 0 00-1.829-1A8.975 8.975 0 005.984 5.83c-2.533 2.536-3.33 6.436-1.962 9.764 1.022 2.487-.653 4.246-2.34 6.022-.599.63-1.199 1.259-1.682 1.925l7.62-6.815'/%3E%3C/svg%3E"); }
.run-mode-dot.copilot { --run-mode-logo: url("data:image/svg+xml,%3Csvg fill-rule='evenodd' viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M12 3C7 3 3 6.5 3 11v4a3 3 0 003 3h12a3 3 0 003-3v-4c0-4.5-4-8-9-8zM8.5 11.5a1.5 2 0 110 4 1.5 2 0 010-4zm7 0a1.5 2 0 110 4 1.5 2 0 010-4z'/%3E%3C/svg%3E"); }
.run-mode-dot.jcode { --run-mode-logo: url("data:image/svg+xml,%3Csvg fill-rule='evenodd' viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M12 2a10 10 0 110 20 10 10 0 010-20zm0 6a4 4 0 100 8 4 4 0 000-8z'/%3E%3C/svg%3E"); }
.run-mode-dot.shell { --run-mode-logo: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2.2' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect x='2.5' y='3.5' width='19' height='17' rx='3'/%3E%3Cpath d='M7 9.5l3 2.5-3 2.5M12.5 15h4.5'/%3E%3C/svg%3E"); }
.run-mode-dot.web { --run-mode-logo: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2'%3E%3Ccircle cx='12' cy='12' r='10'/%3E%3Cpath d='M2 12h20M12 2a15 15 0 0 1 0 20 15 15 0 0 1 0-20'/%3E%3C/svg%3E"); }
@@ -17139,6 +17161,13 @@ html:not([data-skin="og"]) {
color: #f8faff;
}
.btn-toolbar.btn-run.mode-deepseek:hover { box-shadow: 0 0 14px -2px rgba(77, 107, 254, 0.55); }
/* jcode keeps its amber on the non-og skins, same specificity trap as above. */
.btn-toolbar.btn-run.mode-jcode {
background: linear-gradient(135deg, #92400e, #d97706);
border-color: #78350f;
color: #fffbeb;
}
.btn-toolbar.btn-run.mode-jcode:hover { box-shadow: 0 0 14px -2px rgba(245, 158, 11, 0.5); }
.btn-toolbar.btn-run-gear {
background: var(--accent-d);
border-color: var(--accent);
@@ -22467,13 +22496,6 @@ html[data-skin='rose-pine-dawn'] {
padding-bottom: 2.2rem;
}
/* A screen that carries the list of models is no short screen: it starts at the
top and scrolls, or its first lines would be cut off above the frame. */
.local-model-window > .lm-body[data-long='true'] {
justify-content: flex-start;
padding-bottom: 1.2rem;
}
/* The foot of the frame: the buttons of the screen (or the Remove question),
outside the part that scrolls. */
.local-model-window > .lm-foot-slot {
@@ -22815,9 +22837,7 @@ html[data-skin='rose-pine-dawn'] {
.lm-model-tag {
display: flex;
flex-wrap: wrap;
justify-content: flex-start;
gap: 0.25rem;
}
/* A tag says one thing about a row, in the colour of what it says. */
@@ -22853,29 +22873,6 @@ html[data-skin='rose-pine-dawn'] {
color: var(--lm-orange);
}
/* The states of a model that is on this Mac: in use takes the feature's own
colour, ready is good, a changed or missing one is a warning. A model that
was never checked keeps the quiet tag. */
.lm-tag-inuse {
background: color-mix(in srgb, var(--lm-brand) 20%, transparent);
color: var(--lm-brand);
}
.lm-tag-ready {
background: color-mix(in srgb, var(--lm-good) 15%, transparent);
color: var(--lm-good);
}
.lm-tag-changed {
background: color-mix(in srgb, var(--lm-amber) 15%, transparent);
color: var(--lm-amber);
}
.lm-tag-missing {
background: color-mix(in srgb, var(--lm-orange) 15%, transparent);
color: var(--lm-orange);
}
.lm-model-speed,
.lm-model-size {
text-align: end;
@@ -23250,176 +23247,6 @@ html[data-skin='rose-pine-dawn'] {
accent-color: var(--lm-brand);
}
/* ── the models on this Mac ───────────────────────────────────────── */
/* Name with its context, state, size on disk, and what can be done with the
row. One grid like the chooser's, so the sizes line up down the list. The
model in use wears the chooser's selected look: it is the one that runs. */
.lm-library {
display: flex;
flex-direction: column;
gap: 0.55rem;
}
.lm-lib-list {
display: grid;
grid-template-columns: minmax(0, max-content) minmax(0, 1fr) auto auto;
column-gap: 0.75rem;
row-gap: 0.1rem;
margin-inline: calc(-0.7rem - 2px) -0.7rem;
}
.lm-lib-caption,
.lm-lib-row {
display: grid;
grid-column: 1 / -1;
grid-template-columns: subgrid;
align-items: center;
padding: 0 0.7rem;
border-inline-start: 2px solid transparent;
}
.lm-lib-caption {
padding-bottom: 0.3rem;
font-size: 0.7rem;
color: var(--text-dim);
}
.lm-lib-caption-label {
grid-column: 1 / span 2;
}
.lm-lib-caption-size {
grid-column: 3;
text-align: end;
}
.lm-lib-row {
min-height: 2.9rem;
border-radius: 9px;
}
.lm-lib-row-on {
border-inline-start-color: var(--lm-brand);
background: linear-gradient(
90deg,
color-mix(in srgb, var(--lm-brand) 17%, transparent),
color-mix(in srgb, var(--lm-brand) 4%, transparent)
);
}
.lm-lib-name {
display: flex;
flex-direction: column;
min-width: 0;
padding: 0.35rem 0;
}
.lm-lib-label {
font-size: 0.86rem;
font-weight: 600;
line-height: 1.35;
color: var(--text);
overflow-wrap: anywhere;
}
.lm-lib-meta {
font-size: 0.7rem;
line-height: 1.4;
color: var(--text-dim);
font-variant-numeric: tabular-nums;
}
.lm-lib-tag {
display: flex;
justify-content: flex-start;
}
.lm-lib-size {
text-align: end;
white-space: nowrap;
font-size: 0.8rem;
font-weight: 560;
color: var(--text);
font-variant-numeric: tabular-nums;
}
.lm-lib-actions {
display: flex;
flex-shrink: 0;
justify-content: flex-end;
gap: 0.35rem;
}
/* While a row's Delete question is open its buttons keep their room, unseen:
the sizes above and below must not jump sideways. */
.lm-lib-actions-away {
visibility: hidden;
}
/* The buttons of a row are a size smaller than the buttons of the screen. */
.local-model-window .wt-btn.lm-btn.lm-btn-row {
padding: 0.28rem 0.65rem;
font-size: 0.74rem;
}
/* The Delete question of one row, in place under it: the row is what it is about. */
.lm-lib-ask {
display: flex;
grid-column: 1 / -1;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 0.5rem 1rem;
margin: 0.1rem 0.7rem 0.4rem;
padding: 0.6rem 0.75rem;
border: 1px solid var(--border);
border-radius: 9px;
}
.lm-lib-ask-text {
display: flex;
flex-direction: column;
gap: 0.1rem;
min-width: 0;
}
/* Under the list: what all downloads take on disk, and the way to another model. */
.lm-lib-foot {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem 1rem;
}
.lm-lib-notes {
display: flex;
flex-direction: column;
gap: 0.15rem;
min-width: 0;
}
/* Nothing installed, and an earlier Remove kept its downloads. */
.lm-leftovers .lm-lib-ask {
margin-inline: 0;
}
/* A browser without subgrid: fixed tracks keep the columns in line. */
@supports not (grid-template-columns: subgrid) {
.lm-lib-list {
display: block;
}
.lm-lib-caption,
.lm-lib-row {
grid-template-columns: minmax(0, 12rem) minmax(0, 1fr) 4.5rem minmax(0, auto);
column-gap: 0.75rem;
}
.lm-lib-row {
margin-top: 0.1rem;
}
}
/* ── the foot: one promise on the left, the action on the right ──── */
.lm-foot {
display: flex;
@@ -23495,10 +23322,6 @@ html[data-skin='rose-pine-dawn'] {
min-height: 2.9rem;
}
.local-model-window .wt-btn.lm-btn.lm-btn-row {
min-height: 36px;
}
.lm-select {
min-height: 36px;
}
+8 -39
View File
@@ -6,19 +6,15 @@
* |----------------------------------|-----------------------------|------------------------------------------------|
* | `GET /api/local-model` | none | `{ hardware, recommendation, catalogUpdated, install }` |
* | `POST /api/local-model/install` | `LocalModelInstallRequest` | the install view; 400, 409 |
* | `POST /api/local-model/use` | `LocalModelUseRequest` | the install view; 400, 409 |
* | `POST /api/local-model/cancel` | none | the install view; 409 |
* | `POST /api/local-model/resume` | `LocalModelResumeRequest` | the install view; 409 |
* | `POST /api/local-model/runtime` | `LocalModelRuntimeRequest` | the install view; 409 |
* | `DELETE /api/local-model` | `LocalModelRemoveRequest` | the install view; 409 |
* | `DELETE /api/local-model/models` | `LocalModelDeleteRequest` | the install view; 400, 409, 500 |
*
* The GET is built from the hardware probe, the catalogue, the harness facts and the pure
* `recommend()`, plus the `install` object of the install service
* (`LocalModelService.view()`). The one thing joined here: a row of the recommendation that is
* a checked model of the library is marked (`Candidate.library`, by `markLibraryCandidates()`).
* Nothing is reformatted: every size text and every sentence in the payload was chosen by the
* modules that produced it. There is no SSE event:
* (`LocalModelService.view()`). Nothing is reformatted here: every size text and every
* sentence in the payload was chosen by the modules that produced it. There is no SSE event:
* the window polls the GET once a second while it is open.
*
* EXPERIMENTAL (docs/versioning-policy.md, "Experimental local model routes"): the routes and
@@ -49,12 +45,9 @@
* or of the endpoint store that was refused (a file that does not parse) is a 409 without
* `data`, with the fix.
* - The verbs do the work in the install service; this file only gates, parses and maps.
* `DELETE /api/local-model`, `runtime`, `resume`, `install` and `use` look at the live
* sessions: they hand their names to the service, which refuses a removal, a stop, a change
* of port, another model and a switch while one of them still uses the model.
* - `DELETE /api/local-model/models` takes a model NAME in its body, never a path: the schema
* admits only `name:tag`, and the service deletes only a name of its own listing, through
* the runtime.
* `DELETE`, `runtime` and `resume` look at the live sessions: they hand their names to the
* service, which refuses a removal, a stop and a change of port while one of them still
* uses the model.
* - Every dependency is injected (`LocalModelRouteDeps`), so a route test spawns nothing and
* does not need a Mac: it passes its own hardware, harness facts and service.
*/
@@ -90,19 +83,16 @@ import { buildHarnessFacts } from '../../local-model/harness-facts.js';
import {
formatInstallMessage,
getLocalModelService,
markLibraryCandidates,
type LocalModelActor,
type LocalModelService,
type LocalModelVerbResult,
} from '../../local-model/install-job.js';
import { recommend } from '../../local-model/recommend.js';
import {
LocalModelDeleteSchema,
LocalModelInstallSchema,
LocalModelRemoveSchema,
LocalModelResumeSchema,
LocalModelRuntimeSchema,
LocalModelUseSchema,
} from '../schemas.js';
import { SettingsWriteRefusedError, settingsWriteErrorResponse } from '../settings-writer.js';
@@ -166,7 +156,7 @@ export interface LocalModelConflictResponse {
data: LocalModelConflict;
}
/** The 400 (or 500, or 507) of a verb the service refused with a sentence: the error envelope plus that sentence as `data`. */
/** The 400 (or 507) of a verb the service refused with a sentence: the error envelope plus that sentence as `data`. */
export interface LocalModelRefusalResponse {
success: false;
/** The sentence in English, its slots filled. */
@@ -261,12 +251,8 @@ export function registerLocalModelRoutes(
// The measured budget once the runtime reported it, and the runtime download that an
// install would still add: what turns the estimate into the figures of the consent.
const options = await service.recommendOptions();
const recommendation = resolved.recommend(hardware, catalog, harnessFacts, options);
const install = await service.view();
// A row that is already a checked model on this Mac says so: choosing it is a switch.
const recommendation = markLibraryCandidates(
resolved.recommend(hardware, catalog, harnessFacts, options),
install
);
return { success: true, data: { hardware, recommendation, catalogUpdated: catalog.updated, install } };
} catch (err) {
reply.code(500);
@@ -282,17 +268,7 @@ export function registerLocalModelRoutes(
const denied = adminOnly(req, reply);
if (denied) return denied;
const body = parseBody(LocalModelInstallSchema, req.body);
// Asked at the moment of the request: another model takes the runtime from a live session.
const sessionNames = sessionNamesUsingEndpoint(ctx.sessions.values());
return runVerb(reply, () => resolved.service().install(body, actorOf(req), sessionNames));
});
app.post('/api/local-model/use', async (req, reply): Promise<LocalModelVerbResponse> => {
const denied = adminOnly(req, reply);
if (denied) return denied;
const body = parseBody(LocalModelUseSchema, req.body);
const sessionNames = sessionNamesUsingEndpoint(ctx.sessions.values());
return runVerb(reply, () => resolved.service().use(body, actorOf(req), sessionNames));
return runVerb(reply, () => resolved.service().install(body, actorOf(req)));
});
app.post('/api/local-model/cancel', async (req, reply): Promise<LocalModelVerbResponse> => {
@@ -327,11 +303,4 @@ export function registerLocalModelRoutes(
const sessionNames = sessionNamesUsingEndpoint(ctx.sessions.values());
return runVerb(reply, () => resolved.service().remove(body, sessionNames, actorOf(req)));
});
app.delete('/api/local-model/models', async (req, reply): Promise<LocalModelVerbResponse> => {
const denied = adminOnly(req, reply);
if (denied) return denied;
const body = parseBody(LocalModelDeleteSchema, req.body);
return runVerb(reply, () => resolved.service().deleteLibraryModel(body, actorOf(req)));
});
}
+26 -2
View File
@@ -1462,6 +1462,8 @@ export function registerSessionRoutes(
piConfig: mode === 'pi' ? gatedPiConfig : undefined,
grokConfig: mode === 'grok' ? gatedGrokConfig : undefined,
copilotConfig: mode === 'copilot' ? gatedCopilotConfig : undefined,
// No bypass switch to clamp (jcode has no approval prompts), so the body's own config.
jcodeConfig: mode === 'jcode' ? launchBody.jcodeConfig : undefined,
deepSeekConfig: mode === 'deepseek' ? gatedDeepSeekConfig : undefined,
ompConfig: resolveOmpConfigForCreate(mode, workingDir, launchBody.ompConfig),
resumeSessionId: validatedResumeId,
@@ -3226,6 +3228,21 @@ export function registerSessionRoutes(
};
}
// An external CLI with no transcript reader that the pane segmenter cannot read either
// (jcode: a repainting alternate-screen TUI, whose segmented stream served its welcome
// banner as "the answer", measured) has nothing honest to return. Empty, "nothing said
// yet", like the DeepSeek reader above: the Claude scan below would otherwise go looking
// in ~/.claude/projects and could adopt a Claude conversation from the same folder.
// `codeman agent read --source terminal` / MCP `read_output` still read the screen.
{
const caps = getCli(session.mode)?.capabilities;
if (caps?.external && caps.transcript === 'none') {
return context === 'full' || context === 'turn'
? { text: '', timestamp: '', messages: [] }
: { text: '', timestamp: '' };
}
}
// Scan ~/.claude/projects/*/ for the transcript file
const projectsDir = join(process.env.HOME || '/tmp', '.claude', 'projects');
@@ -4248,6 +4265,7 @@ export function registerSessionRoutes(
piConfig,
grokConfig,
copilotConfig,
jcodeConfig,
deepSeekConfig,
ompConfig,
envOverrides,
@@ -4320,6 +4338,7 @@ export function registerSessionRoutes(
piConfig ||
grokConfig ||
copilotConfig ||
jcodeConfig ||
deepSeekConfig ||
ompConfig ||
openCodeConfig ||
@@ -4391,6 +4410,7 @@ export function registerSessionRoutes(
piConfig ||
grokConfig ||
copilotConfig ||
jcodeConfig ||
deepSeekConfig ||
ompConfig ||
openCodeConfig ||
@@ -4492,6 +4512,7 @@ export function registerSessionRoutes(
piConfig,
grokConfig,
copilotConfig,
jcodeConfig,
deepSeekConfig,
} as unknown as Record<string, unknown>)
);
@@ -4579,7 +4600,7 @@ export function registerSessionRoutes(
writeFileSync(join(resolvedCasePath, 'CLAUDE.md'), claudeMd);
// Write .claude/settings.local.json with hooks for desktop notifications
// (Claude-specific — OpenCode, Codex, Gemini, Antigravity, Pi, Grok, DeepSeek and OMP use their own systems)
// (Claude-specific — OpenCode, Codex, Gemini, Antigravity, Pi, Grok, DeepSeek, OMP and jcode use their own systems)
if (
mode !== 'opencode' &&
mode !== 'codex' &&
@@ -4587,7 +4608,8 @@ export function registerSessionRoutes(
mode !== 'antigravity' &&
mode !== 'pi' &&
mode !== 'grok' &&
mode !== 'omp'
mode !== 'omp' &&
mode !== 'jcode'
) {
await writeHooksConfig(resolvedCasePath);
}
@@ -4695,6 +4717,7 @@ export function registerSessionRoutes(
piConfig,
grokConfig,
copilotConfig,
jcodeConfig,
deepSeekConfig,
ompConfig,
};
@@ -4914,6 +4937,7 @@ export function registerSessionRoutes(
piConfig: mode === 'pi' ? qsGatedPiConfig : undefined,
grokConfig: mode === 'grok' ? qsGatedGrokConfig : undefined,
copilotConfig: mode === 'copilot' ? qsGatedCopilotConfig : undefined,
jcodeConfig: mode === 'jcode' ? qsLaunchConfigs.jcodeConfig : undefined,
deepSeekConfig: mode === 'deepseek' ? qsGatedDeepSeekConfig : undefined,
ompConfig: qsResolvedOmpConfig,
envOverrides: qsGatedEnvOverrides,
+14
View File
@@ -584,6 +584,20 @@ export function registerSystemRoutes(
};
});
// ========== jcode ==========
// The sibling shape (path + version), answered by the GENERIC registry resolver rather than a
// module of its own: it is what a jcode spawn resolves the binary with, and it already applies
// the entry's `jcode v<semver>` version anchor (npm carries an unrelated `jcode` package).
app.get('/api/jcode/status', async () => {
const { isCliAvailable, resolveCliBinDir, resolveCliVersion } = await import('../../utils/cli-resolver.js');
return {
available: isCliAvailable('jcode'),
path: resolveCliBinDir('jcode'),
version: resolveCliVersion('jcode'),
};
});
// ========== DeepSeek Harness ==========
// The widest of the per-CLI status shapes, because this mode has the widest
+27 -6
View File
@@ -493,6 +493,30 @@ const CopilotConfigSchema = z
})
.optional();
/**
* Schema for jcode (1jehuang/jcode)-specific configuration.
*
* jcode has no approval prompts and no bypass flag, so there is nothing for the
* multi-user clamp to touch.
*/
const JcodeConfigSchema = z
.object({
model: z
.string()
.max(100)
.regex(/^[a-zA-Z0-9._\-/]+$/)
.optional(),
// A session id (`session_fox_<ms>_<hex>`) or short name (`fox`). Leading
// alphanumeric: --resume's value is optional, so a value starting with `-`
// would be parsed as a flag of its own.
resumeSessionId: z
.string()
.max(100)
.regex(/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/)
.optional(),
})
.optional();
/**
* Schema for OMP CLI-specific configuration.
*/
@@ -651,6 +675,7 @@ export const CreateSessionSchema = z.object({
piConfig: PiConfigSchema,
grokConfig: GrokConfigSchema,
copilotConfig: CopilotConfigSchema,
jcodeConfig: JcodeConfigSchema,
deepSeekConfig: DeepSeekConfigSchema,
ompConfig: OmpConfigSchema,
/** Resume a previous Claude conversation by its session ID (used for reboot recovery) */
@@ -870,6 +895,7 @@ const RemoteCommandOverridesSchema = z
deepseek: z.string().min(1).max(300).optional(),
omp: z.string().min(1).max(300).optional(),
copilot: z.string().min(1).max(300).optional(),
jcode: z.string().min(1).max(300).optional(),
})
.strict()
.optional();
@@ -1259,6 +1285,7 @@ export const QuickStartSchema = z.object({
piConfig: PiConfigSchema,
grokConfig: GrokConfigSchema,
copilotConfig: CopilotConfigSchema,
jcodeConfig: JcodeConfigSchema,
deepSeekConfig: DeepSeekConfigSchema,
ompConfig: OmpConfigSchema,
envOverrides: safeEnvOverridesSchema,
@@ -2505,12 +2532,6 @@ export const LocalModelRuntimeSchema = z
/** DELETE /api/local-model. The body may be empty. */
export const LocalModelRemoveSchema = z.object({ removeModel: z.boolean().optional() }).strict();
/** POST /api/local-model/use: a checked model of the library to switch to. */
export const LocalModelUseSchema = z.object({ modelId: localModelId }).strict();
/** DELETE /api/local-model/models: one model of the store, by its name. Never a path. */
export const LocalModelDeleteSchema = z.object({ modelId: localModelId }).strict();
/**
* A shell-safe bare word, mirroring `config/cli-registry/schema.ts`'s own `shellToken` —
* duplicated rather than imported, since the REAL safety boundary for anything built from
+1
View File
@@ -4171,6 +4171,7 @@ export class WebServer extends EventEmitter {
piConfig: muxSession.mode === 'pi' ? savedState?.piConfig : undefined,
grokConfig: muxSession.mode === 'grok' ? savedState?.grokConfig : undefined,
copilotConfig: muxSession.mode === 'copilot' ? savedState?.copilotConfig : undefined,
jcodeConfig: muxSession.mode === 'jcode' ? savedState?.jcodeConfig : undefined,
deepSeekConfig: muxSession.mode === 'deepseek' ? savedState?.deepSeekConfig : undefined,
ompConfig: muxSession.mode === 'omp' ? savedState?.ompConfig : undefined,
envOverrides: savedEnvOverrides,
+2 -1
View File
@@ -37,6 +37,7 @@ describe('per-mode capability predicates', () => {
['deepseek', true, false, true],
['omp', true, false, false],
['copilot', true, false, false],
['jcode', true, false, false],
] as Array<[SessionMode, boolean, boolean, boolean]>)(
'%s: external=%s altScreenStrip=%s hooks=%s',
(mode, external, altScreen, hooks) => {
@@ -49,7 +50,7 @@ describe('per-mode capability predicates', () => {
it('covers every enabled mode (sanity)', () => {
// If a CLI is added without a row above, this fails rather than the table silently
// describing a subset of reality.
expect(MODES.length).toBe(11);
expect(MODES.length).toBe(12);
});
it('keeps the three predicates genuinely distinct', () => {
+4 -1
View File
@@ -97,6 +97,7 @@ const ALLOWED_BRANCHES: Record<string, Allowance> = {
"web/routes/session-routes.ts::mode === 'grok'": allow(2, 'legacy <Mode>Config plumbing'),
"web/routes/session-routes.ts::mode === 'copilot'": allow(2, 'legacy <Mode>Config plumbing'),
"web/routes/session-routes.ts::mode === 'deepseek'": allow(2, 'legacy <Mode>Config plumbing'),
"web/routes/session-routes.ts::mode === 'jcode'": allow(2, 'legacy <Mode>Config plumbing'),
"web/server.ts::mode === 'opencode'": allow(1, 'legacy <Mode>Config plumbing (session recovery)'),
"web/server.ts::mode === 'codex'": allow(1, 'legacy <Mode>Config plumbing (session recovery)'),
"web/server.ts::mode === 'gemini'": allow(1, 'legacy <Mode>Config plumbing (session recovery)'),
@@ -106,6 +107,7 @@ const ALLOWED_BRANCHES: Record<string, Allowance> = {
"web/server.ts::mode === 'copilot'": allow(1, 'legacy <Mode>Config plumbing (session recovery)'),
"web/server.ts::mode === 'deepseek'": allow(1, 'legacy <Mode>Config plumbing (session recovery)'),
"web/server.ts::mode === 'omp'": allow(1, 'legacy <Mode>Config plumbing (session recovery)'),
"web/server.ts::mode === 'jcode'": allow(1, 'legacy <Mode>Config plumbing (session recovery)'),
// --- Claude's remote/docker command construction ---
"tmux-manager.ts::mode === 'claude'": allow(
@@ -174,7 +176,7 @@ const ALLOWED_BRANCHES: Record<string, Allowance> = {
'chain (see the opencode entry below)'
),
// ⚠️ Scaffolded-case hooks. This chain excludes seven CLIs but NOT `deepseek`, while its
// ⚠️ Scaffolded-case hooks. This chain excludes eight CLIs but NOT `deepseek`, while its
// own comment says DeepSeek uses its own system — so a scaffolded deepseek case gets a
// Claude hooks block written into it. That inconsistency is UPSTREAM's and predates this
// change; expressing the chain as a capability would have to pick a side and would
@@ -189,6 +191,7 @@ const ALLOWED_BRANCHES: Record<string, Allowance> = {
"web/routes/session-routes.ts::mode !== 'antigravity'": allow(1, 'scaffolded-case hooks (see the opencode entry)'),
"web/routes/session-routes.ts::mode !== 'pi'": allow(1, 'scaffolded-case hooks (see the opencode entry)'),
"web/routes/session-routes.ts::mode !== 'grok'": allow(1, 'scaffolded-case hooks (see the opencode entry)'),
"web/routes/session-routes.ts::mode !== 'jcode'": allow(1, 'scaffolded-case hooks (see the opencode entry)'),
};
/** Every stock CLI id, derived rather than restated so a new entry is covered automatically. */
+3 -3
View File
@@ -645,12 +645,12 @@ describe('capability shapes', () => {
});
it('declares transcriptPageKeys for exactly the CLIs measured to page on PageUp/PageDown', () => {
// claude (repaint mode), codex, opencode and copilot (all on the alternate screen) were
// measured paging their own transcript on PageUp/PageDown; no other stock entry has been.
// claude (repaint mode), codex, opencode, copilot and jcode (all on the alternate screen)
// were measured paging their own transcript on PageUp/PageDown; no other stock entry has been.
// pi pages too when fullscreen, and stays out on purpose: in `tuiMode: regular` the same keys
// move the caret of a multi-line draft.
const paging = STOCK_CLIS.filter((e) => e.capabilities.transcriptPageKeys === true).map((e) => e.id as string);
expect(paging.sort()).toEqual(['claude', 'codex', 'copilot', 'opencode']);
expect(paging.sort()).toEqual(['claude', 'codex', 'copilot', 'jcode', 'opencode']);
});
});
+21
View File
@@ -326,6 +326,27 @@ describe('grok', () => {
});
});
describe('jcode', () => {
const jc = (jcodeConfig?: SpawnBridgeOptions['jcodeConfig']) => render({ mode: 'jcode', sessionId: SID, jcodeConfig });
it('spawns bare by default: no bypass flag exists to send', () => {
expect(jc()).toBe('jcode');
});
it('passes --model and --resume', () => {
expect(jc({ model: 'claude-opus-4-8' })).toBe('jcode --model claude-opus-4-8');
expect(jc({ resumeSessionId: 'session_fox_1792160401272_f5b47ce47d631006' })).toBe(
'jcode --resume session_fox_1792160401272_f5b47ce47d631006'
);
expect(jc({ model: 'gpt-5.5', resumeSessionId: 'fox' })).toBe('jcode --model gpt-5.5 --resume fox');
});
it('drops a resume value that would read as a flag of its own', () => {
// --resume's value is optional in jcode, so `--resume --no-update` is two flags.
expect(jc({ resumeSessionId: '--no-update' })).toBe('jcode');
});
});
describe('deepseek', () => {
const ds = (deepSeekConfig?: SpawnBridgeOptions['deepSeekConfig']) =>
render({ mode: 'deepseek', sessionId: SID, deepSeekConfig });
+35 -93
View File
@@ -18,7 +18,6 @@
* | `GET /api/ps` | the model once a chat loaded it (`size`, `size_vram`, `context_length`) |
* | `POST /api/pull` | NDJSON progress, see `FakePullSwitches` |
* | `DELETE /api/delete` | 200, or 404 for a model that is not installed |
* | | (the model, or one of `otherModels`) |
* | `GET /v1/models` | the model's id, or `data: null` when none is installed (as the real one) |
* | `POST /v1/chat/completions` | a plain answer, or a tool call when the request carries `tools` |
* | `GET /download/ollama-darwin.tgz` | a small real `.tgz` with Range support, see `FakeArchiveSwitches` |
@@ -221,14 +220,6 @@ export interface FakeOllamaOptions {
spawn?: Partial<FakeSpawnSwitches>;
/** The files of the fake runtime archive, name to content. Default: one executable `ollama` script. */
archiveFiles?: Record<string, string>;
/**
* More models the registry has beside `model`: each can be pulled (a short stream that
* succeeds), listed, loaded, checked and deleted on its own. The pull, chat and tool
* switches apply to them too, except the pull's failures and shapes, which are `model`'s.
*/
otherModels?: FakeOllamaModel[];
/** The ids of `otherModels` that are on disk from the start. */
othersInstalled?: string[];
}
export interface CapturedRequest {
@@ -313,10 +304,6 @@ export interface FakeOllama {
archiveRequests: number;
/** Every child `spawn` made, oldest first. */
children: FakeRuntimeChild[];
/** The ids of `otherModels` that are on disk: pulled, or installed from the start. */
others: string[];
/** The id of the model the last chat loaded. The runtime holds one model at a time. */
loadedId?: string;
};
/** The fake runtime archive: a real gzipped tar. */
readonly archive: { readonly body: Buffer; readonly size: number; readonly sha256: string };
@@ -449,26 +436,7 @@ export function createFakeOllama(options: FakeOllamaOptions = {}): FakeOllama {
pulls: 0,
archiveRequests: 0,
children: [],
others: [...(options.othersInstalled ?? [])],
};
const otherModels = options.otherModels ?? [];
/** One of `otherModels` by its id, whether it is on disk or not. */
const otherModel = (id: unknown): FakeOllamaModel | undefined => otherModels.find((other) => other.id === id);
/** The model `id` names when it is on disk: `model` or one of the others. */
const onDisk = (id: unknown): FakeOllamaModel | undefined => {
if (id === model.id) return state.installed ? model : undefined;
const other = otherModel(id);
return other && state.others.includes(other.id) ? other : undefined;
};
const tagRow = (entry: FakeOllamaModel): Record<string, unknown> => ({
name: entry.id,
model: entry.id,
modified_at: '2026-11-06T15:10:02.465346347+01:00',
size: entry.bytes,
digest: entry.digest,
details: { ...modelDetails(), format: entry.format, context_length: 262144, runner: entry.runner },
capabilities: entry.capabilities,
});
const requests: CapturedRequest[] = [];
const sockets = new Set<Socket>();
let server: Server | undefined;
@@ -507,18 +475,6 @@ export function createFakeOllama(options: FakeOllamaOptions = {}): FakeOllama {
};
if (!(await line({ status: 'pulling manifest' }))) return;
// One of the other models: a short stream that succeeds.
const other = otherModel(askedId);
if (other && pull.fail !== 'not-found') {
const layer = { status: 'pulling model', digest: 'sha256:other', total: other.bytes };
if (!(await line({ ...layer, completed: 0 }))) return;
if (!(await line({ ...layer, completed: other.bytes }))) return;
if (!(await line({ status: 'writing manifest' }))) return;
if (!state.others.includes(other.id)) state.others.push(other.id);
await line({ status: 'success' });
res.end();
return;
}
// Not captured on a Mac: the wording of the runtime for a tag the registry does not have.
if (pull.fail === 'not-found' || askedId !== model.id) {
await line({ error: 'pull model manifest: file does not exist' });
@@ -572,8 +528,7 @@ export function createFakeOllama(options: FakeOllamaOptions = {}): FakeOllama {
function handleChat(res: ServerResponse, body: unknown): void {
const request = (body as { model?: unknown; tools?: unknown } | undefined) ?? {};
const served = onDisk(request.model);
if (!served) {
if (!state.installed || request.model !== model.id) {
sendJson(res, 404, {
error: {
message: `model '${String(request.model)}' not found`,
@@ -589,7 +544,7 @@ export function createFakeOllama(options: FakeOllamaOptions = {}): FakeOllama {
id: `chatcmpl-${chatCounter++}`,
object: 'chat.completion',
created: 1793968038,
model: served.id,
model: model.id,
system_fingerprint: 'fp_ollama',
choices: [{ index: 0, message: { role: 'assistant', ...message }, finish_reason: finishReason }],
usage: {
@@ -608,18 +563,12 @@ export function createFakeOllama(options: FakeOllamaOptions = {}): FakeOllama {
});
return;
}
if (switches.ps.loaded !== false) {
state.loaded = true;
state.loadedId = served.id;
}
if (switches.ps.loaded !== false) state.loaded = true;
sendJson(res, 200, envelope({ content: 'pong' }, 'stop'));
return;
}
if (switches.ps.loaded !== false) {
state.loaded = true;
state.loadedId = served.id;
}
if (switches.ps.loaded !== false) state.loaded = true;
const fn = (tools[0] as { function?: { name?: unknown; parameters?: { properties?: unknown } } }).function ?? {};
const name = typeof fn.name === 'string' ? fn.name : 'tool';
const properties = (fn.parameters?.properties ?? {}) as Record<string, unknown>;
@@ -699,67 +648,60 @@ export function createFakeOllama(options: FakeOllamaOptions = {}): FakeOllama {
}
if (path === '/api/version' && method === 'GET') return sendJson(res, 200, { version });
if (path === '/api/tags' && method === 'GET') {
const models = [
...(state.installed ? [tagRow(model)] : []),
...otherModels.filter((other) => state.others.includes(other.id)).map(tagRow),
];
return sendJson(res, 200, { models });
}
if (path === '/api/ps' && method === 'GET') {
// The runtime holds ONE model: the one the last chat loaded.
const held = state.loaded && switches.ps.loaded !== false ? onDisk(state.loadedId ?? model.id) : undefined;
const size = switches.ps.size ?? (held ?? model).bytes + 100_000_000;
const models = held
const models = state.installed
? [
{
name: held.id,
model: held.id,
size,
digest: held.digest,
details: modelDetails(),
expires_at: '2026-11-06T15:20:09.488045+01:00',
size_vram: switches.ps.sizeVram ?? size,
context_length: switches.ps.contextLength ?? contextLength,
runner: held.runner,
name: model.id,
model: model.id,
modified_at: '2026-11-06T15:10:02.465346347+01:00',
size: model.bytes,
digest: model.digest,
details: { ...modelDetails(), context_length: 262144, runner: model.runner },
capabilities: model.capabilities,
},
]
: [];
return sendJson(res, 200, { models });
}
if (path === '/api/ps' && method === 'GET') {
const size = switches.ps.size ?? model.bytes + 100_000_000;
const models =
state.installed && state.loaded && switches.ps.loaded !== false
? [
{
name: model.id,
model: model.id,
size,
digest: model.digest,
details: modelDetails(),
expires_at: '2026-11-06T15:20:09.488045+01:00',
size_vram: switches.ps.sizeVram ?? size,
context_length: switches.ps.contextLength ?? contextLength,
runner: model.runner,
},
]
: [];
return sendJson(res, 200, { models });
}
if (path === '/api/pull' && method === 'POST') return handlePull(res, body);
if (path === '/api/delete' && method === 'DELETE') {
// Not captured: the answers of a delete (200 with no body, 404 with an error text).
const asked = (body as { model?: unknown; name?: unknown } | undefined) ?? {};
const askedId = typeof asked.model === 'string' ? asked.model : asked.name;
const other = otherModel(askedId);
if (other && state.others.includes(other.id)) {
state.others = state.others.filter((id) => id !== other.id);
if (state.loadedId === other.id) {
state.loaded = false;
state.loadedId = undefined;
}
res.writeHead(200);
res.end();
return;
}
if (!state.installed || askedId !== model.id) {
return sendJson(res, 404, { error: `model '${String(askedId)}' not found` });
}
state.installed = false;
state.loaded = false;
state.loadedId = undefined;
state.completedBytes = 0;
res.writeHead(200);
res.end();
return;
}
if (path === '/v1/models' && method === 'GET') {
const ids = [
...(state.installed ? [model.id] : []),
...otherModels.filter((other) => state.others.includes(other.id)).map((other) => other.id),
];
const data =
ids.length > 0 ? ids.map((id) => ({ id, object: 'model', created: 1793967749, owned_by: 'library' })) : null;
const data = state.installed
? [{ id: model.id, object: 'model', created: 1793967749, owned_by: 'library' }]
: null;
return sendJson(res, 200, { object: 'list', data });
}
if (path === '/v1/chat/completions' && method === 'POST') return handleChat(res, body);
+4
View File
@@ -93,6 +93,9 @@ const LITERAL_SEARCH_PATHS: Record<string, string[]> = {
'$HOME/.npm-global/bin/copilot',
'$HOME/bin/copilot',
],
// Not from a hand-written array: jcode arrived after the generated block replaced them, so
// this is the registry's own list (the installer's ~/.local/bin target, then the image's).
JCODE: ['$HOME/.local/bin/jcode', '/usr/local/bin/jcode', '$HOME/bin/jcode'],
};
/** Array prefix in `install.sh` -> registry id, for the two that differ. */
@@ -107,6 +110,7 @@ const ARRAY_PREFIX_TO_CLI_ID: Record<string, string> = {
ANTIGRAVITY: 'antigravity',
OMP: 'omp',
COPILOT: 'copilot',
JCODE: 'jcode',
};
/**
+259
View File
@@ -0,0 +1,259 @@
/**
* @fileoverview jcode (1jehuang/jcode) as a run mode: its wire config, its spawn command,
* its capability flags, and the screen-reading patterns that tell a working jcode pane from
* one at rest.
*
* The pane fixtures are verbatim captures (`tmux capture-pane -p`, rows shortened to their
* tail) and verbatim tmux CLIENT stream chunks, both from a live jcode v0.98.1 pane driven
* through real turns on 2026-10-16: a plain reply, a bash tool call, and a streamed answer.
* The client stream matters because that is what Codeman's PTY reads, and tmux redraws only
* the cells that changed, so the composer glyph has to survive that diffing.
*
* Port: none (pure functions plus a Session with a scripted fake pane).
*/
import { describe, expect, it, afterEach, vi } from 'vitest';
import { CreateSessionSchema, QuickStartSchema } from '../src/web/schemas.js';
import { buildSpawnCommand } from '../src/tmux-manager.js';
import { defaultDockerCommandForMode } from '../src/docker-hosts.js';
import { defaultRemoteCommandForMode, remoteLoginShellCommand } from '../src/remote-hosts.js';
import { Session, isExternalCliMode, isAltScreenStripMode } from '../src/session.js';
import { hooksAvailableForMode } from '../src/web/session-wait-registry.js';
import { getCli } from '../src/config/cli-registry/index.js';
import { compileVersionRegex } from '../src/config/cli-registry/patterns.js';
import { promptStillInComposer } from '../src/session-submit-verifier.js';
import { isExternalCliTranscriptMode } from '../src/web/response-viewer-transcript.js';
const jcode = getCli('jcode');
const workDetect = jcode?.capabilities.workDetect;
const workingLine = compileVersionRegex(workDetect?.workingLine ?? '');
/** The composer and the status bar under it, as every capture ends (bar shortened). */
const FOOTER_REST = '\n5>\n …/scratchpad/jwork master ?1 17k/252k ▰▱▱▱▱▱▱▱▱▱ 7% qwen5090 qwen3.8 27b\n';
const FOOTER_BUSY = '\n5…\n …/scratchpad/jwork master ?1 17k/252k ▰▱▱▱▱▱▱▱▱▱ 7% qwen5090 qwen3.8 27b\n';
const PROMPT_ROW = ' 4› Use your bash tool to run exactly: sleep 8; echo slept. Then write a 40 word paragraph.\n\n';
/** One status row per phase a turn goes through, each verbatim from a capture. */
const WORKING_ROWS = [
'⠋ sending… 0s',
'⠦ thinking… 2s · https',
'⠼ waiting for response… 3s · https',
'⠧ running bash · Run user-requested sleep probe · https · 2s · Alt+B bg',
'⠙ 11s · 182.7 tps · ↑17k ↓156 · https',
];
const atRest =
`${PROMPT_ROW} The command ran and printed slept after 8 seconds.\n\n` +
` Donuts are ring-shaped treats, glazed with sugary sweetness.\n` +
// The turn's summary row: a time, but no spinner, so it must never read as work.
` 21s · 199.6 tps · ↑17k ↓2k\n${FOOTER_REST}`;
/**
* The tmux client stream (what Codeman's PTY reads). Mid-turn it is the spinner cell being
* repainted on its own; the turn's end repaints the composer row, and the only cell there
* that changes is `…` turning into `>` (the prompt number is not resent).
*/
const SPINNER_CHUNK = '\x1b[38;1H\x1b[38;5;114m⠏\x1b[39;4H\x1b(B\x1b[m';
const TURN_END_CHUNK =
'\x1b[40;2H\x1b[38;5;251mDONE\x1b[41;2H\x1b[39m \x1b[38;5;239m10s · 189.1 tps · ↑16k ↓15\r\n' +
'\x1b[39m \x1b[43;2H\x1b[38;5;111m> \x1b(B\x1b[m';
describe('jcode wire config', () => {
it('accepts a model and a resume id or short name on both create paths', () => {
const created = CreateSessionSchema.parse({
workingDir: '/tmp',
mode: 'jcode',
jcodeConfig: { model: 'claude-opus-4-8', resumeSessionId: 'session_fox_1792160401272_f5b47ce47d631006' },
});
expect(created.jcodeConfig?.resumeSessionId).toBe('session_fox_1792160401272_f5b47ce47d631006');
const quick = QuickStartSchema.parse({ caseName: 'jc', mode: 'jcode', jcodeConfig: { resumeSessionId: 'fox' } });
expect(quick.jcodeConfig?.resumeSessionId).toBe('fox');
});
it('refuses a resume value that would parse as a flag, and an unsafe model', () => {
// --resume takes an OPTIONAL value, so `--resume --no-update` would read as two flags.
for (const resumeSessionId of ['--no-update', '-x', 'fox; rm -rf ~', 'a b']) {
expect(
CreateSessionSchema.safeParse({ workingDir: '/tmp', mode: 'jcode', jcodeConfig: { resumeSessionId } }).success,
resumeSessionId
).toBe(false);
}
expect(
CreateSessionSchema.safeParse({ workingDir: '/tmp', mode: 'jcode', jcodeConfig: { model: 'a`b' } }).success
).toBe(false);
});
it('admits no JCODE_* env override', () => {
// Most of that namespace is read by jcode's shared daemon, hooks and command paths
// included, so a per-session value would either do nothing or reach every session.
expect(
CreateSessionSchema.safeParse({ workingDir: '/tmp', mode: 'jcode', envOverrides: { JCODE_HOME: '/tmp/x' } })
.success
).toBe(false);
expect(jcode?.env.allowedPrefixes).toEqual([]);
});
});
describe('jcode spawn command', () => {
const spawn = (jcodeConfig?: Parameters<typeof buildSpawnCommand>[0]['jcodeConfig']) =>
buildSpawnCommand({ mode: 'jcode', sessionId: 'abc12345', jcodeConfig });
it('spawns bare, then with --model and --resume when asked', () => {
expect(spawn()).toBe('jcode');
expect(spawn({ model: 'gpt-5.5' })).toBe('jcode --model gpt-5.5');
expect(spawn({ resumeSessionId: 'session_fox_1792160401272_f5b47ce47d631006' })).toBe(
'jcode --resume session_fox_1792160401272_f5b47ce47d631006'
);
expect(spawn({ model: 'gpt-5.5', resumeSessionId: 'fox' })).toBe('jcode --model gpt-5.5 --resume fox');
});
it('drops a value the schema would have refused rather than emit it', () => {
// Defence in depth behind the schema: the argv engine re-checks every token.
expect(spawn({ resumeSessionId: '--no-update' })).toBe('jcode');
expect(spawn({ model: 'a`b' })).toBe('jcode');
});
it('has remote and docker pane commands', () => {
expect(defaultDockerCommandForMode('jcode')).toBe('exec jcode');
expect(defaultRemoteCommandForMode('jcode')).toBe(remoteLoginShellCommand('jcode'));
});
});
describe('jcode capabilities', () => {
it('is an external CLI with no hooks, its own strip, and no agent skill', () => {
expect(isExternalCliMode('jcode')).toBe(true);
expect(isAltScreenStripMode('jcode')).toBe(false);
expect(hooksAvailableForMode('jcode')).toBe(false);
expect(jcode?.capabilities.altScreen).toBe('strip-mux-and-mouse');
expect(jcode?.capabilities.transcriptPageKeys).toBe(true);
// Its tools run in the shared daemon, under whichever session started it, so an
// injected skill would act as that session.
expect(jcode?.capabilities.agentSkillInjection).toBe(false);
expect(jcode?.capabilities.privilegedParams).toEqual([]);
});
it('anchors its version probe on the `jcode v` banner', () => {
const version = jcode?.discovery.version;
expect(version?.requireVersionMatch).toBe(true);
const re = compileVersionRegex(version?.regex ?? '');
expect(re?.exec('jcode v0.98.1 (1c7426eb1)')?.[1]).toBe('0.98.1');
// The unrelated npm package of the same name, or any other `jcode`, is absent.
expect(re?.exec('1.0.0')).toBeNull();
});
it('is never segmented off its pane by the response viewer', () => {
expect(isExternalCliTranscriptMode('jcode')).toBe(false);
});
});
describe("jcode's work-detection descriptor", () => {
it('compiles through the regex guard', () => {
expect(workingLine).not.toBeNull();
});
it('matches every status row a turn shows, in a full capture', () => {
for (const row of WORKING_ROWS) {
expect(workingLine!.test(`${PROMPT_ROW}${row}${FOOTER_BUSY}`), row).toBe(true);
}
});
it('reads past a minute and an hour, a dropped network, and a centred layout', () => {
expect(workingLine!.test(`${PROMPT_ROW}⠸ thinking… 1m 3s · https${FOOTER_BUSY}`)).toBe(true);
expect(workingLine!.test(`${PROMPT_ROW}⠸ running bash · build · 1h 2m · Alt+B bg${FOOTER_BUSY}`)).toBe(true);
expect(
workingLine!.test(`${PROMPT_ROW}↻ network disconnected, waiting to retry · sse · 5s${FOOTER_BUSY}`)
).toBe(true);
expect(workingLine!.test(`${PROMPT_ROW} ⠸ thinking… 4s${FOOTER_BUSY}`)).toBe(true);
});
it('does not match a pane at rest, its summary row, or a braille glyph in the transcript', () => {
expect(workingLine!.test(atRest)).toBe(false);
expect(workingLine!.test(`${PROMPT_ROW} the spinner looked like ⠋ for 3s\n${FOOTER_REST}`)).toBe(false);
});
it('arms the idle check from the turn-ending repaint, and nothing mid-turn carries the glyph', () => {
expect(TURN_END_CHUNK).toContain(workDetect!.promptGlyph);
expect(SPINNER_CHUNK).not.toContain(workDetect!.promptGlyph);
});
it('leaves the submit verifier inert: no screen row starts with the glyph', () => {
// The composer row is `<n>> …`, so the verifier finds no composer line and never presses
// Enter on its own (jcode has no early-Enter defect for it to work around).
const typed = `${PROMPT_ROW}${FOOTER_REST.replace('5>', '5> say ok')}`;
expect(promptStillInComposer(typed, 'say ok', workDetect!.promptGlyph)).toBeUndefined();
expect(promptStillInComposer(atRest, 'say ok', workDetect!.promptGlyph)).toBeUndefined();
});
});
describe('jcode interactive idle detection', () => {
afterEach(() => {
vi.useRealTimers();
});
type SessionInternals = {
_handleTerminalOutput(data: string): void;
_detectInteractiveActivity(data: string): void;
};
function feed(session: Session, data: string): void {
const internals = session as unknown as SessionInternals;
internals._handleTerminalOutput(data);
internals._detectInteractiveActivity(data);
}
function withFakePane(read: () => string): Session {
const mux = {
isAvailable: () => true,
capturePaneText: () => read(),
} as unknown as NonNullable<ConstructorParameters<typeof Session>[0]>['mux'];
return new Session({
workingDir: '/tmp',
mode: 'jcode',
mux,
muxSession: { muxName: 'codeman-test', sessionId: 'test', createdAt: Date.now() },
} as ConstructorParameters<typeof Session>[0]);
}
/** The spinner cell repainted every 80 ms, as jcode's ~12 fps indicator does. */
function spin(session: Session, ms: number): void {
for (let t = 0; t < ms; t += 80) {
feed(session, SPINNER_CHUNK);
vi.advanceTimersByTime(80);
}
}
it('marks a turn working off its spinner and ends it on the composer repaint', () => {
vi.useFakeTimers();
let screen = `${PROMPT_ROW}${WORKING_ROWS[3]}${FOOTER_BUSY}`;
const session = withFakePane(() => screen);
const events: string[] = [];
session.on('working', () => events.push('working'));
session.on('idle', () => events.push('idle'));
spin(session, 8000);
expect(events).toEqual(['working']);
expect(session.status).toBe('busy');
screen = atRest;
feed(session, TURN_END_CHUNK);
vi.advanceTimersByTime(20_000);
expect(events).toEqual(['working', 'idle']);
expect(session.status).toBe('idle');
});
it('does not end a turn while the status row is still on screen', () => {
vi.useFakeTimers();
// A quiet pane whose screen still shows the spinner row (a stalled stream): the
// screen says working, so silence alone must not conclude idle.
const screen = `${PROMPT_ROW}${WORKING_ROWS[2]}${FOOTER_BUSY}`;
const session = withFakePane(() => screen);
const events: string[] = [];
session.on('working', () => events.push('working'));
session.on('idle', () => events.push('idle'));
spin(session, 4000);
feed(session, TURN_END_CHUNK);
vi.advanceTimersByTime(20_000);
expect(events).toEqual(['working']);
expect(session.status).toBe('busy');
});
});
+1 -149
View File
@@ -345,7 +345,7 @@ describe('the install from the first GET to Remove, real modules over a fake run
endpointId: 'local-ollama',
folder: world.paths.root,
loaded: { context: pick.context, onGpu: true },
actions: ['start-session', 'stop', 'install', 'remove'],
actions: ['start-session', 'stop', 'remove'],
runtime: { installed: true, downloadBytes: 0, downloadGb: formatGb(0) },
});
@@ -682,154 +682,6 @@ describe('the install from the first GET to Remove, real modules over a fake run
}, 60_000);
});
describe('the library: a second model, the switch back, and the delete of one model', () => {
it('installs A, adds B beside it, switches back to A, and deletes B, with one model in use throughout', async () => {
const { app, get, settled, install: clickInstall } = await routes();
const hostsModels = async () =>
(await readCustomModelHosts(getDataDir())).find((host) => host.id === 'local-ollama')?.models;
const post = (url: string, payload: unknown) => app.inject({ method: 'POST', url, payload: payload as object });
const deleteModel = (modelId: string) =>
app.inject({ method: 'DELETE', url: '/api/local-model/models', payload: { modelId } });
// ---- A: the first install ----
world.agentInstalled = true;
const first = await get();
const a = first.recommendation.pick!;
const bRow = first.recommendation.alternatives[0];
expect(bRow).toBeDefined();
expect(bRow.modelId).not.toBe(a.modelId);
const B_DIGEST = 'b'.repeat(64);
world.runtime = createFakeOllama({
model: { id: a.modelId, bytes: a.downloadBytes },
contextLength: a.context,
otherModels: [
{
id: bRow.modelId,
bytes: bRow.downloadBytes,
digest: B_DIGEST,
format: 'gguf',
runner: 'ggml',
quantization: 'Q4_K_M',
capabilities: ['completion', 'tools'],
},
],
});
const runtime = world.runtime;
expect((await clickInstall(a)).statusCode).toBe(200);
expect((await settled()).state).toBe('installed');
let status = await get();
expect(status.install.library).toEqual([
{
id: a.modelId,
label: a.modelLabel,
context: a.context,
sizeBytes: a.downloadBytes,
sizeGb: formatGb(a.downloadBytes),
active: true,
verified: true,
status: 'ready',
actions: [],
},
]);
// The store of the fake runtime writes no blobs: measured, and empty.
expect(status.install.store).toEqual({ bytes: 0, gb: '0.0' });
// The chooser says the row is the model in use.
expect(status.recommendation.pick?.library).toBe('active');
expect(status.install.actions).toEqual(['start-session', 'stop', 'install', 'remove']);
// ---- B: a second model. Refused while a session uses A. ----
const b = [status.recommendation.pick!, ...status.recommendation.alternatives].find(
(row) => row.modelId === bRow.modelId
)!;
// The runtime is on disk now, so the server's total for B is the model alone.
expect(b.installBytes).toBe(b.downloadBytes);
const addB = { modelId: b.modelId, cli: 'opencode', context: b.context, acknowledgedBytes: b.installBytes };
world.sessions.set('s1', { name: 'w1-app', customModel: { endpointId: 'local-ollama' } });
const busy = await post('/api/local-model/install', addB);
expect(busy.statusCode).toBe(409);
expect(busy.json().data).toMatchObject({ reason: 'sessions-open', sessions: ['w1-app'] });
expect(runtime.state.pulls).toBe(1);
world.sessions.clear();
// The fake runtime reports the context it was "started with": B's from here on.
runtime.switches.ps.contextLength = b.context;
const started = await post('/api/local-model/install', addB);
expect(started.statusCode).toBe(200);
expect(started.json().data).toMatchObject({ state: 'installing', model: { id: b.modelId } });
const withB = await settled();
expect(withB.error).toBeUndefined();
expect(withB).toMatchObject({
state: 'installed',
model: { id: b.modelId, label: b.modelLabel },
context: b.context,
endpointId: 'local-ollama',
});
// Both are on disk, A was not deleted, and the ONE endpoint record lists the model in use.
expect(runtime.state.installed).toBe(true);
expect(runtime.state.others).toEqual([b.modelId]);
expect(await hostsModels()).toEqual([b.modelId]);
expect(runtime.requests.some((request) => request.path === '/api/delete')).toBe(false);
await vi.waitFor(async () => {
const library = (await get()).install.library;
expect(library.map((row) => [row.id, row.active, row.status, row.actions])).toEqual([
[b.modelId, true, 'ready', []],
[a.modelId, false, 'ready', ['use', 'delete']],
]);
});
const stored = await readLocalModelState();
expect(stored.library?.map((entry) => [entry.id, entry.context, entry.digest])).toEqual([
[a.modelId, a.context, runtime.model.digest],
[b.modelId, b.context, B_DIGEST],
]);
// Only the model in use may be launched on the endpoint.
expect(await world.service.launchRefusalMessage('local-ollama', b.modelId)).toBeUndefined();
expect(await world.service.launchRefusalMessage('local-ollama', a.modelId)).toContain('is in use on this Mac');
// ---- The model in use cannot be deleted ----
const active = await deleteModel(b.modelId);
expect(active.statusCode).toBe(409);
expect(active.json().data).toMatchObject({ reason: 'model-active' });
expect(runtime.state.others).toEqual([b.modelId]);
// ---- Back to A: nothing is downloaded ----
status = await get();
expect(status.recommendation.pick?.library ?? status.recommendation.alternatives[0].library).toBeDefined();
world.sessions.set('s1', { name: 'w1-app', customModel: { endpointId: 'local-ollama' } });
const useBusy = await post('/api/local-model/use', { modelId: a.modelId });
expect(useBusy.statusCode).toBe(409);
expect(useBusy.json().data).toMatchObject({ reason: 'sessions-open' });
world.sessions.clear();
runtime.switches.ps.contextLength = a.context;
const pullsBefore = runtime.state.pulls;
const switched = await post('/api/local-model/use', { modelId: a.modelId });
expect(switched.statusCode).toBe(200);
expect(switched.json().data).toMatchObject({ state: 'installing', steps: ['verify', 'connect'] });
const backOnA = await settled();
expect(backOnA.error).toBeUndefined();
expect(backOnA).toMatchObject({ state: 'installed', model: { id: a.modelId }, context: a.context });
expect(runtime.state.pulls).toBe(pullsBefore);
expect(await hostsModels()).toEqual([a.modelId]);
expect(await world.service.launchRefusalMessage('local-ollama', a.modelId)).toBeUndefined();
// ---- Delete B: through the runtime, by its name ----
const notOurs = await deleteModel('someone-else:7b');
expect(notOurs.statusCode).toBe(400);
const gone = await deleteModel(b.modelId);
expect(gone.statusCode).toBe(200);
expect(gone.json().data.library.map((row: { id: string }) => row.id)).toEqual([a.modelId]);
expect(runtime.state.others).toEqual([]);
expect(runtime.state.installed).toBe(true);
const deletes = runtime.requests.filter((request) => request.path === '/api/delete');
expect(deletes.map((request) => request.body)).toEqual([{ model: b.modelId }]);
expect((await readLocalModelState()).library?.map((entry) => entry.id)).toEqual([a.modelId]);
expect((await get()).install.actions).toEqual(['start-session', 'stop', 'install', 'remove']);
// A is in use: it can only go with the whole installation.
expect((await deleteModel(a.modelId)).statusCode).toBe(409);
});
});
describe('a server that is killed leaves its runtime running: the next server never deletes under it', () => {
/**
* Installs up to the model download, then "kills" the server: its job stops and it signals
+25 -454
View File
@@ -17,18 +17,13 @@ import {
allowedActions,
buildInstallError,
buildInstallView,
buildLibraryRows,
formatInstallMessage,
INSTALL_STATE_TABLE,
INSTALL_STEP_TABLE,
INSTALL_TRANSITIONS,
installedBlocker,
jobSteps,
libraryOf,
markLibraryCandidates,
reduceInstallJob,
upsertLibraryEntry,
USE_JOB_STEPS,
type InstallJobEvent,
type InstallJobEventType,
type InstallLiveFacts,
@@ -43,15 +38,12 @@ import {
type LocalModelErrorCode,
type LocalModelInstallState,
type LocalModelInstallStep,
type LocalModelLibraryEntry,
type LocalModelStateFile,
type Recommendation,
} from '../src/types.js';
const STATES: LocalModelInstallState[] = ['idle', 'installing', 'paused', 'failed', 'installed'];
const EVENT_TYPES: InstallJobEventType[] = [
'install',
'use',
'progress',
'step-done',
'fail',
@@ -68,48 +60,6 @@ const RUNTIME = { version: '0.40.15', port: 11435, pid: 4711, enabled: true, mea
const CONNECTION = { endpointId: 'local-ollama', flagWasEnabled: false };
const LOADED = { context: 131072, sizeBytes: 26_300_000_000, sizeVramBytes: 26_300_000_000 };
/** The model in use as the library lists it, and a second checked model beside it. */
const ENTRY: LocalModelLibraryEntry = {
id: MODEL.id,
label: MODEL.label,
context: MODEL.context,
digest: 'abc123',
bytes: 23_105_798_956,
cli: CLI,
};
const OTHER: LocalModelLibraryEntry = {
id: 'qwen3.8:27b-mtp-q4_k_m',
label: 'Qwen3.8 27B',
context: 65536,
digest: 'def456',
bytes: 17_741_872_154,
cli: CLI,
};
/** The two as rows of the view, while nothing is known from the runtime. */
const ENTRY_ROW = {
id: ENTRY.id,
label: ENTRY.label,
context: ENTRY.context,
sizeBytes: 23_105_798_956,
sizeGb: '23.1',
active: true,
verified: true,
status: 'ready',
actions: [],
};
const OTHER_ROW = {
id: OTHER.id,
label: OTHER.label,
context: OTHER.context,
sizeBytes: 17_741_872_154,
sizeGb: '17.7',
active: false,
verified: true,
status: 'ready',
actions: ['use'],
};
const JOB: LocalModelStateFile = {
schema: 1,
state: 'installing',
@@ -119,8 +69,6 @@ const JOB: LocalModelStateFile = {
cli: CLI,
runtime: RUNTIME,
progress: { completedBytes: 6_200_000_000, totalBytes: 23_105_798_956 },
// An earlier install left one checked model: what `use` can go back to.
library: [OTHER],
};
/** One state file of each state, as the job leaves it. */
@@ -136,7 +84,6 @@ const SAMPLE: Record<LocalModelInstallState, LocalModelStateFile> = {
cli: CLI,
runtime: RUNTIME,
loaded: LOADED,
library: [OTHER],
connection: CONNECTION,
},
};
@@ -149,11 +96,6 @@ const EVENT: Record<InstallJobEventType, InstallJobEvent> = {
cli: { id: 'claude', label: 'Claude Code' },
steps: ['runtime', 'model', 'verify', 'connect'],
},
use: {
type: 'use',
model: { id: OTHER.id, label: OTHER.label, context: OTHER.context, digest: OTHER.digest },
cli: CLI,
},
progress: { type: 'progress', completedBytes: 7_000_000_000, totalBytes: 23_105_798_956 },
'step-done': { type: 'step-done' },
fail: { type: 'fail', code: 'no-tool-call', vars: { model: 'Qwen3.6 35B-A3B' }, nextModelId: 'gpt-oss:20b' },
@@ -209,17 +151,12 @@ describe('the tables', () => {
cancel: 'paused',
restart: 'paused',
},
paused: { resume: 'installing', install: 'installing', use: 'installing', remove: 'idle', restart: 'paused' },
failed: { resume: 'installing', install: 'installing', use: 'installing', remove: 'idle', restart: 'failed' },
installed: { install: 'installing', use: 'installing', remove: 'idle', restart: 'installed' },
paused: { resume: 'installing', install: 'installing', remove: 'idle', restart: 'paused' },
failed: { resume: 'installing', install: 'installing', remove: 'idle', restart: 'failed' },
installed: { remove: 'idle', restart: 'installed' },
});
});
it('a switch is the two steps that load and connect a model, and downloads nothing', () => {
expect(USE_JOB_STEPS).toEqual(['verify', 'connect']);
for (const step of USE_JOB_STEPS) expect(INSTALL_STEP_TABLE[step].progress).toBe(false);
});
it('every step row names error codes that exist, each with a sentence and a next step', () => {
for (const step of LOCAL_MODEL_INSTALL_STEPS) {
const row = INSTALL_STEP_TABLE[step];
@@ -284,10 +221,10 @@ describe('reduceInstallJob: every state with every event, from the transition ta
}
}
it('covers all 45 pairs, 22 of them listed', () => {
it('covers all 40 pairs, 18 of them listed', () => {
const listed = STATES.flatMap((state) => Object.keys(INSTALL_TRANSITIONS[state]));
expect(STATES.length * EVENT_TYPES.length).toBe(45);
expect(listed).toHaveLength(22);
expect(STATES.length * EVENT_TYPES.length).toBe(40);
expect(listed).toHaveLength(18);
});
});
@@ -309,15 +246,11 @@ describe('the state table agrees with the reducer and the view', () => {
const row = INSTALL_STATE_TABLE[state];
const accepts = (type: InstallJobEventType) => INSTALL_TRANSITIONS[state][type] !== undefined;
expect(row.actions.includes('install')).toBe(accepts('install'));
expect(row.actions.includes('use')).toBe(accepts('use'));
expect(row.actions.includes('cancel')).toBe(accepts('cancel'));
expect(row.actions.includes('resume')).toBe(accepts('resume'));
// Remove is refused only while a job runs. From idle it only clears what an earlier
// Remove kept, and the view lists it there only while the store holds something.
expect(row.actions.includes('remove')).toBe(accepts('remove'));
// A model is deleted through the runtime, which a state without a job in flight and with
// a runtime set up can have: never idle (no runtime), never while a job runs.
expect(row.actions.includes('delete-model')).toBe(state !== 'idle' && state !== 'installing');
// Remove is refused only while a job runs. From idle it only clears leftovers, so the
// window offers no button for it there.
if (state !== 'idle') expect(row.actions.includes('remove')).toBe(accepts('remove'));
// The runtime and a session are things of an installed model only.
for (const action of ['start', 'stop', 'start-session'] as LocalModelAction[]) {
expect(row.actions.includes(action)).toBe(state === 'installed');
@@ -359,7 +292,6 @@ describe('reduceInstallJob: what each event writes', () => {
replacedModelId: 'qwen3.6:35b-a3b',
cli: { id: 'claude', label: 'Claude Code' },
runtime: RUNTIME,
library: [OTHER],
connection: CONNECTION,
});
// Gone: the error, the progress, what the runtime had loaded.
@@ -489,65 +421,32 @@ describe('reduceInstallJob: what each event writes', () => {
});
describe('allowedActions', () => {
it('outside installed the runtime, the port and the digest narrow nothing', () => {
const busy = { ...LIVE, running: true, gaveUp: true, portInUse: true };
expect(allowedActions(SAMPLE.installing, busy)).toEqual(['cancel']);
expect(allowedActions(SAMPLE.installing, LIVE)).toEqual(['cancel']);
for (const state of ['paused', 'failed'] as const) {
// `use` because a checked model is there; no delete while nothing is known from the runtime.
expect(allowedActions(SAMPLE[state], busy)).toEqual(['resume', 'install', 'use', 'remove']);
expect(allowedActions(SAMPLE[state], LIVE)).toEqual(['resume', 'install', 'use', 'remove']);
it('outside installed it is the state row, whatever is live', () => {
for (const state of ['idle', 'installing', 'paused', 'failed'] as const) {
expect(allowedActions(SAMPLE[state], { ...LIVE, running: true, gaveUp: true, portInUse: true })).toEqual([
...INSTALL_STATE_TABLE[state].actions,
]);
}
});
it('idle: Remove is offered only while the model store still holds something', () => {
expect(allowedActions(SAMPLE.idle, LIVE)).toEqual(['install']);
expect(allowedActions(SAMPLE.idle, { ...LIVE, storeBytes: 0 })).toEqual(['install']);
expect(allowedActions(SAMPLE.idle, { ...LIVE, storeBytes: 23_105_798_956 })).toEqual(['install', 'remove']);
expect(allowedActions(SAMPLE.installing, LIVE)).toEqual(['cancel']);
expect(allowedActions(SAMPLE.paused, LIVE)).toEqual(['resume', 'install', 'remove']);
expect(allowedActions(SAMPLE.failed, LIVE)).toEqual(['resume', 'install', 'remove']);
});
it('use and delete-model are listed only while a row of the library offers them', () => {
// A first job: no checked model yet, so nothing to switch to and nothing to delete.
const first: LocalModelStateFile = { ...SAMPLE.paused, library: undefined };
expect(allowedActions(first, LIVE)).toEqual(['resume', 'install', 'remove']);
// The runtime lists the other model: it can be deleted as well as switched to.
const tags = [{ name: OTHER.id, size: 17_741_872_154, digest: OTHER.digest }];
expect(allowedActions(SAMPLE.paused, { ...LIVE, running: true, tags })).toEqual([
'resume',
'install',
'use',
'delete-model',
'remove',
]);
// The rows it was given decide, when it is given them.
expect(allowedActions(SAMPLE.paused, { ...LIVE, running: true, tags }, [])).toEqual([
'resume',
'install',
'remove',
]);
});
it('installed and running: a session can be started, the runtime stopped, a model added', () => {
it('installed and running: a session can be started and the runtime stopped', () => {
expect(allowedActions(SAMPLE.installed, { ...LIVE, running: true, digest: 'same' })).toEqual([
'start-session',
'stop',
'install',
'use',
'remove',
]);
});
it('installed and stopped: it can be started, and no session is offered', () => {
expect(allowedActions(SAMPLE.installed, LIVE)).toEqual(['start', 'install', 'use', 'remove']);
expect(allowedActions(SAMPLE.installed, LIVE)).toEqual(['start', 'remove']);
});
it('installed and running a model that changed: no session', () => {
expect(allowedActions(SAMPLE.installed, { ...LIVE, running: true, digest: 'changed' })).toEqual([
'stop',
'install',
'use',
'remove',
]);
expect(allowedActions(SAMPLE.installed, { ...LIVE, running: true, digest: 'changed' })).toEqual(['stop', 'remove']);
});
it('a digest that could not be read does not hide the session button: the launch itself reads it again', () => {
@@ -557,8 +456,8 @@ describe('allowedActions', () => {
it('never returns the table row itself', () => {
const actions = allowedActions(SAMPLE.idle, LIVE);
actions.push('cancel');
expect(INSTALL_STATE_TABLE.idle.actions).toEqual(['install', 'remove']);
actions.push('remove');
expect(INSTALL_STATE_TABLE.idle.actions).toEqual(['install']);
});
});
@@ -636,20 +535,10 @@ describe('buildInstallView', () => {
unpackedGb: '0.6',
},
folder: '/Users/me/.codeman/local-model',
library: [],
actions: ['install'],
});
});
it('idle with a store an earlier Remove kept: its size, and Remove to free it', () => {
const view = buildInstallView(SAMPLE.idle, { ...LIVE, storeBytes: 23_105_798_956 });
expect(view.store).toEqual({ bytes: 23_105_798_956, gb: '23.1' });
expect(view.library).toEqual([]);
expect(view.actions).toEqual(['install', 'remove']);
// A store that was measured as empty is said as that, and offers nothing.
expect(buildInstallView(SAMPLE.idle, { ...LIVE, storeBytes: 0 }).store).toEqual({ bytes: 0, gb: '0.0' });
});
it('once the runtime is unpacked an install downloads nothing for it', () => {
const view = buildInstallView(SAMPLE.idle, { ...LIVE, runtimeInstalled: true });
expect(view.runtime).toEqual({
@@ -675,8 +564,6 @@ describe('buildInstallView', () => {
unpackedGb: '0.6',
},
folder: '/Users/me/.codeman/local-model',
// The checked model of an earlier install: listed, with nothing to do while a job runs.
library: [{ ...OTHER_ROW, actions: [] }],
actions: ['cancel'],
model: { id: 'qwen3.6:35b-a3b', label: 'Qwen3.6 35B-A3B' },
context: 131072,
@@ -706,9 +593,7 @@ describe('buildInstallView', () => {
completedGb: '6.2',
totalGb: '23.1',
});
expect(view.actions).toEqual(['resume', 'install', 'use', 'remove']);
// The way back from a job that stopped: the model that was checked before.
expect(view.library).toEqual([OTHER_ROW]);
expect(view.actions).toEqual(['resume', 'install', 'remove']);
});
it('a speed of zero has no time left, and a download without a total has no progress at all', () => {
@@ -792,8 +677,7 @@ describe('buildInstallView', () => {
developerTools: 'present',
runtime: { version: '0.40.15', installed: true, downloadBytes: 0, downloadGb: '0.0', unpackedGb: '0.6' },
folder: '/Users/me/.codeman/local-model',
library: [ENTRY_ROW, OTHER_ROW],
actions: ['start-session', 'stop', 'install', 'use', 'remove'],
actions: ['start-session', 'stop', 'remove'],
model: { id: 'qwen3.6:35b-a3b', label: 'Qwen3.6 35B-A3B' },
context: 131072,
cli: 'opencode',
@@ -808,9 +692,7 @@ describe('buildInstallView', () => {
const view = buildInstallView(SAMPLE.installed, { ...LIVE, running: true, digest: 'changed' });
expect(view.state).toBe('installed');
expect(view.error?.code).toBe('model-changed');
expect(view.actions).toEqual(['stop', 'install', 'use', 'remove']);
// The row of the model in use says the same thing the error does.
expect(view.library[0]).toMatchObject({ id: MODEL.id, active: true, status: 'changed' });
expect(view.actions).toEqual(['stop', 'remove']);
});
it('a model that was not all on the GPU says so', () => {
@@ -831,317 +713,6 @@ describe('buildInstallView', () => {
});
});
describe('reduceInstallJob: the library', () => {
it('libraryOf: the list of the file, and for an installed file without one its model', () => {
expect(libraryOf(SAMPLE.idle)).toEqual([]);
expect(libraryOf(SAMPLE.paused)).toEqual([OTHER]);
// A file an earlier build wrote: the model in use is the library.
const old: LocalModelStateFile = { ...SAMPLE.installed, library: undefined };
expect(libraryOf(old)).toEqual([ENTRY]);
// The model in use is always one of them, first, also when the list does not name it.
expect(libraryOf(SAMPLE.installed)).toEqual([ENTRY, OTHER]);
// Never twice, and the list's own entry wins.
const listed: LocalModelStateFile = { ...SAMPLE.installed, library: [OTHER, { ...ENTRY, context: 65536 }] };
expect(libraryOf(listed)).toEqual([OTHER, { ...ENTRY, context: 65536 }]);
// A model without a stored digest could never be compared: it is not one of them.
const noDigest: LocalModelStateFile = { ...SAMPLE.installed, library: undefined, model: MODEL };
expect(libraryOf(noDigest)).toEqual([]);
// A job's model is not in the library before it was checked.
expect(libraryOf({ ...SAMPLE.installing, library: undefined })).toEqual([]);
});
it('libraryOf hands out a list of its own', () => {
const list = libraryOf(SAMPLE.paused);
list.pop();
expect(SAMPLE.paused.library).toEqual([OTHER]);
});
it('upsertLibraryEntry replaces the entry with the same id in place, else adds at the end', () => {
const next = { ...OTHER, digest: 'fff', context: 131072 };
expect(upsertLibraryEntry([ENTRY, OTHER], next)).toEqual([ENTRY, next]);
expect(upsertLibraryEntry([ENTRY], OTHER)).toEqual([ENTRY, OTHER]);
expect(upsertLibraryEntry([], OTHER)).toEqual([OTHER]);
});
it('an install from installed adds a model: the model in use stays in the library and is NOT deleted', () => {
const next = reduceInstallJob(SAMPLE.installed, EVENT.install);
expect(next).toEqual({
schema: 1,
state: 'installing',
step: 'runtime',
steps: ['runtime', 'model', 'verify', 'connect'],
model: { id: 'gpt-oss:20b', label: 'GPT-OSS 20B', context: 65536 },
cli: { id: 'claude', label: 'Claude Code' },
runtime: RUNTIME,
// The model that was in use is written into the list: its state member is the new job's now.
library: [ENTRY, OTHER],
connection: CONNECTION,
});
expect(next).not.toHaveProperty('replacedModelId');
expect(next).not.toHaveProperty('loaded');
});
it('an install from an installed file of an earlier build keeps its one model too', () => {
const old: LocalModelStateFile = { ...SAMPLE.installed, library: undefined };
const next = reduceInstallJob(old, EVENT.install);
expect(next.library).toEqual([ENTRY]);
expect(next).not.toHaveProperty('replacedModelId');
});
it('a replaced job deletes its model only when that model was never checked', () => {
// The stopped job's model is a checked one (it failed after its check): it stays.
const checked: LocalModelStateFile = { ...SAMPLE.failed, library: [OTHER, ENTRY] };
expect(reduceInstallJob(checked, EVENT.install)).not.toHaveProperty('replacedModelId');
// Not checked: what it left is deleted, as before.
expect(reduceInstallJob(SAMPLE.failed, EVENT.install).replacedModelId).toBe(MODEL.id);
// A leftover that is waiting to be deleted is dropped from the wait once it is a checked model.
const waiting: LocalModelStateFile = { ...SAMPLE.paused, replacedModelId: OTHER.id };
expect(reduceInstallJob(waiting, { ...EVENT.install, model: MODEL } as InstallJobEvent)).not.toHaveProperty(
'replacedModelId'
);
});
it('use from installed: the two steps for the checked model, with its stored digest', () => {
expect(reduceInstallJob(SAMPLE.installed, EVENT.use)).toEqual({
schema: 1,
state: 'installing',
step: 'verify',
steps: ['verify', 'connect'],
model: { id: OTHER.id, label: OTHER.label, context: OTHER.context, digest: OTHER.digest },
cli: CLI,
runtime: RUNTIME,
library: [ENTRY, OTHER],
connection: CONNECTION,
});
});
it('use from a stopped job replaces it, and deletes what that job left of an unchecked model', () => {
for (const state of ['paused', 'failed'] as const) {
const next = reduceInstallJob(SAMPLE[state], EVENT.use);
expect(next.state).toBe('installing');
expect(next.step).toBe('verify');
expect(next.model?.id).toBe(OTHER.id);
expect(next.replacedModelId).toBe(MODEL.id);
expect(next).not.toHaveProperty('error');
expect(next).not.toHaveProperty('progress');
expect(next.library).toEqual([OTHER]);
}
});
it('refuses a use it cannot apply: a model that is not in the library, a state without a runtime', () => {
const unknown = { ...EVENT.use, model: { id: 'gpt-oss:20b', label: 'GPT-OSS 20B', context: 65536, digest: 'x' } };
expect(reduceInstallJob(SAMPLE.installed, unknown as InstallJobEvent)).toBe(SAMPLE.installed);
const noRuntime: LocalModelStateFile = { ...SAMPLE.paused, runtime: undefined };
expect(reduceInstallJob(noRuntime, EVENT.use)).toBe(noRuntime);
});
it('every event but remove carries the library', () => {
const job = SAMPLE.installing;
expect(reduceInstallJob(job, EVENT.progress).library).toEqual([OTHER]);
expect(reduceInstallJob(job, EVENT['step-done']).library).toEqual([OTHER]);
expect(reduceInstallJob(job, EVENT.fail).library).toEqual([OTHER]);
expect(reduceInstallJob(job, EVENT.cancel).library).toEqual([OTHER]);
expect(reduceInstallJob(job, EVENT.restart).library).toEqual([OTHER]);
expect(reduceInstallJob(SAMPLE.failed, EVENT.resume).library).toEqual([OTHER]);
const last: LocalModelStateFile = { ...job, step: 'connect', library: [OTHER, ENTRY], connection: CONNECTION };
expect(reduceInstallJob(last, EVENT['step-done'])).toMatchObject({ state: 'installed', library: [OTHER, ENTRY] });
expect(reduceInstallJob(SAMPLE.installed, EVENT.remove)).toEqual({ schema: 1, state: 'idle' });
});
});
describe('buildLibraryRows', () => {
const tagOf = (entry: LocalModelLibraryEntry, extra: Partial<{ size: number; digest: string }> = {}) => ({
name: entry.id,
size: entry.bytes ?? 0,
digest: entry.digest,
...extra,
});
it('is empty in idle, whatever is live', () => {
expect(buildLibraryRows(SAMPLE.idle, { ...LIVE, running: true, tags: [tagOf(OTHER)] })).toEqual([]);
});
it('the runtime stopped: the checked models as they were recorded, a switch, and no delete', () => {
expect(buildLibraryRows(SAMPLE.installed, LIVE)).toEqual([ENTRY_ROW, OTHER_ROW]);
// A listing that is still around from before the stop is not trusted.
expect(buildLibraryRows(SAMPLE.installed, { ...LIVE, running: false, tags: [] })).toEqual([ENTRY_ROW, OTHER_ROW]);
});
it('the runtime running: its sizes, and a delete for every model but the one in use', () => {
const rows = buildLibraryRows(SAMPLE.installed, {
...LIVE,
running: true,
tags: [tagOf(ENTRY, { size: 23_105_800_000 }), tagOf(OTHER)],
});
expect(rows).toEqual([
{ ...ENTRY_ROW, sizeBytes: 23_105_800_000 },
{ ...OTHER_ROW, actions: ['use', 'delete'] },
]);
});
it('the model in use comes first, wherever the list has it', () => {
const state: LocalModelStateFile = { ...SAMPLE.installed, library: [OTHER, ENTRY] };
expect(buildLibraryRows(state, LIVE).map((row) => row.id)).toEqual([ENTRY.id, OTHER.id]);
});
it('a checked model whose digest the runtime no longer reports: changed, never switched to, deletable', () => {
const rows = buildLibraryRows(SAMPLE.installed, {
...LIVE,
running: true,
tags: [tagOf(ENTRY), tagOf(OTHER, { digest: 'somethingelse' })],
});
expect(rows[1]).toMatchObject({ id: OTHER.id, status: 'changed', verified: true, actions: ['delete'] });
// The digest is compared without its prefix and its case, like everywhere else.
const same = buildLibraryRows(SAMPLE.installed, {
...LIVE,
running: true,
tags: [tagOf(ENTRY), tagOf(OTHER, { digest: 'sha256:DEF456' })],
});
expect(same[1].status).toBe('ready');
});
it('a checked model the runtime no longer lists: missing, with its recorded size, and its entry can be dropped', () => {
const rows = buildLibraryRows(SAMPLE.installed, { ...LIVE, running: true, tags: [tagOf(ENTRY)] });
expect(rows[1]).toEqual({ ...OTHER_ROW, status: 'missing', actions: ['delete'] });
});
it('what the runtime lists beside the checked models: not checked, deletable, never switched to', () => {
const rows = buildLibraryRows(SAMPLE.installed, {
...LIVE,
running: true,
labels: { 'mimo-v2.6:9b-q4_k_m': 'MiMo V2.6 9B' },
tags: [
tagOf(ENTRY),
tagOf(OTHER),
{ name: 'zeta:1b', size: 900_000_000, digest: 'z' },
{ name: 'mimo-v2.6:9b-q4_k_m', size: 6_550_811_033, digest: 'm' },
],
});
expect(rows.slice(2)).toEqual([
{
id: 'mimo-v2.6:9b-q4_k_m',
// The catalogue knows it, so it is named as the chooser names it.
label: 'MiMo V2.6 9B',
sizeBytes: 6_550_811_033,
sizeGb: '6.6',
active: false,
verified: false,
status: 'unverified',
actions: ['delete'],
},
{
id: 'zeta:1b',
label: 'zeta:1b',
sizeBytes: 900_000_000,
sizeGb: '0.9',
active: false,
verified: false,
status: 'unverified',
actions: ['delete'],
},
]);
});
it('a name that is not a plain name:tag is listed with its size and offers nothing', () => {
const rows = buildLibraryRows(SAMPLE.installed, {
...LIVE,
running: true,
tags: [tagOf(ENTRY), { name: 'hf.co/someone/model:Q4', size: 1_000_000_000, digest: 'h' }],
});
expect(rows.find((row) => row.id === 'hf.co/someone/model:Q4')).toMatchObject({
status: 'unverified',
sizeGb: '1.0',
actions: [],
});
});
it('the model of a stopped job: listed once it is on disk, and neither switched to nor deleted', () => {
const rows = buildLibraryRows(SAMPLE.failed, {
...LIVE,
running: true,
tags: [tagOf(OTHER), { name: MODEL.id, size: 23_105_798_956, digest: 'abc123' }],
});
expect(rows).toEqual([
{ ...OTHER_ROW, actions: ['use', 'delete'] },
{
id: MODEL.id,
label: MODEL.label,
sizeBytes: 23_105_798_956,
sizeGb: '23.1',
active: false,
verified: false,
status: 'unverified',
actions: [],
},
]);
});
it('while a job runs nothing is offered on any row', () => {
const rows = buildLibraryRows(SAMPLE.installing, { ...LIVE, running: true, tags: [tagOf(OTHER)] });
expect(rows.map((row) => row.actions)).toEqual([[]]);
});
it('a row without a known size has neither size member', () => {
const state: LocalModelStateFile = { ...SAMPLE.paused, library: [{ ...OTHER, bytes: undefined }] };
const [row] = buildLibraryRows(state, LIVE);
expect(row).not.toHaveProperty('sizeBytes');
expect(row).not.toHaveProperty('sizeGb');
});
});
describe('markLibraryCandidates', () => {
const candidate = (modelId: string, context: number) => ({ modelId, context }) as Recommendation['alternatives'][0];
const view = buildInstallView(SAMPLE.installed, { ...LIVE, running: true });
it('marks the model in use and a model a switch can take, at the context they were checked with', () => {
const recommendation = {
verdict: 'ok',
pick: candidate(MODEL.id, MODEL.context),
alternatives: [candidate(OTHER.id, OTHER.context), candidate('gpt-oss:20b', 65536)],
} as unknown as Recommendation;
const marked = markLibraryCandidates(recommendation, view);
expect(marked.pick?.library).toBe('active');
expect(marked.alternatives[0].library).toBe('ready');
expect(marked.alternatives[1]).not.toHaveProperty('library');
// The input is not written to.
expect(recommendation.pick).not.toHaveProperty('library');
});
it('a row at another context than the one that was checked is an install like any other', () => {
const recommendation = {
verdict: 'ok',
pick: candidate(MODEL.id, 65536),
alternatives: [candidate(OTHER.id, 131072)],
} as unknown as Recommendation;
expect(markLibraryCandidates(recommendation, view)).toBe(recommendation);
});
it('hands back the same object when there is nothing to mark', () => {
const none = { verdict: 'none', alternatives: [] } as unknown as Recommendation;
expect(markLibraryCandidates(none, view)).toBe(none);
const idle = buildInstallView(SAMPLE.idle, LIVE);
const recommendation = { verdict: 'ok', pick: candidate(MODEL.id, MODEL.context), alternatives: [] };
expect(markLibraryCandidates(recommendation as unknown as Recommendation, idle)).toBe(recommendation);
});
it('a checked model that changed is not marked ready', () => {
const changed = buildInstallView(SAMPLE.installed, {
...LIVE,
running: true,
tags: [
{ name: MODEL.id, size: 1, digest: 'abc123' },
{ name: OTHER.id, size: 1, digest: 'other' },
],
});
const recommendation = {
verdict: 'ok',
pick: candidate(OTHER.id, OTHER.context),
alternatives: [],
} as unknown as Recommendation;
expect(markLibraryCandidates(recommendation, changed).pick).not.toHaveProperty('library');
});
});
describe('formatInstallMessage', () => {
it('fills the slots of a sentence', () => {
expect(formatInstallMessage({ text: LOCAL_MODEL_INSTALL_LABELS.errPortInUse, vars: { port: '11435' } })).toBe(
+15 -779
View File
@@ -13,9 +13,7 @@
* Cancel in every step; Resume; a job a server restart interrupted comes back paused and
* waits for a click; every failure is a state with a code and a next step; the coding agent
* install (the stock command, the audit line, the resolver caches forgotten, still missing is
* a failure); the digest that refuses a changed model; the runtime verbs; Remove; and the
* library: a second model beside the first, the switch between checked models, the delete of
* one model, what a stopped job leaves to go back to, and the two cached listings of the view.
* a failure); the digest that refuses a changed model; the runtime verbs; Remove.
*
* Port: N/A (nothing listens).
*/
@@ -261,10 +259,6 @@ function setup(
running: false,
gaveUp: false,
pid: 5000,
/** What the runtime lists on disk. Default: the model of the state, as it was pulled. */
tags: undefined as { name: string; size: number; digest: string }[] | undefined,
/** What the model store takes on disk. */
storeBytes: 0,
};
const calls: string[] = [];
@@ -331,20 +325,14 @@ function setup(
{ name: 'qwen3.6:35b-a3b', size: 26_300_000_000, sizeVram: 26_300_000_000, contextLength: 32768 },
]),
getRuntimeVersion: vi.fn(async () => '0.40.15' as string | undefined),
deleteModel: vi.fn(async (_target: unknown, _id: string) => {
deleteModel: vi.fn(async () => {
calls.push('deleteModel');
return true;
}),
listTags: vi.fn(async () => {
if (world.tags) return world.tags;
const model = store.get().model;
return model ? [{ name: model.id, size: PULL_BYTES, digest: DIGEST }] : [];
}),
removePartialModelBlobs: vi.fn(async () => {
calls.push('removePartialModelBlobs');
return 0;
}),
measureModelStore: vi.fn(async () => world.storeBytes),
checkDeveloperTools: vi.fn(async () => 'missing' as const),
stockInstallCommand: vi.fn((id: string) => (id === FACTS[0].cli ? INSTALL_COMMAND : undefined)),
tryBeginCliInstall: vi.fn((): (() => void) | null => () => {
@@ -482,7 +470,7 @@ describe('a job from the click to installed', () => {
port: 11435,
loaded: { context: 131072, memoryBytes: 26_300_000_000, memoryGb: '26.3', onGpu: true },
endpointId: 'local-ollama',
actions: ['start-session', 'stop', 'install', 'remove'],
actions: ['start-session', 'stop', 'remove'],
folder: '/data/local-model',
runtime: { version: '0.40.15', installed: true, downloadBytes: 0 },
});
@@ -711,8 +699,6 @@ describe('one job at a time', () => {
const jobRunning = { ok: false, status: 409, conflict: { reason: 'job-running' } };
expect(await h.service.install(REQUEST, ADMIN)).toMatchObject(jobRunning);
expect(await h.service.resume({}, ADMIN)).toMatchObject(jobRunning);
expect(await h.service.use({ modelId: 'gpt-oss:20b' }, ADMIN)).toMatchObject(jobRunning);
expect(await h.service.deleteLibraryModel({ modelId: 'gpt-oss:20b' }, ADMIN)).toMatchObject(jobRunning);
expect(await h.service.runtime({ action: 'stop' })).toMatchObject(jobRunning);
expect(await h.service.remove({}, [])).toMatchObject(jobRunning);
expect(h.deps.ensureRuntimeInstalled).toHaveBeenCalledTimes(1);
@@ -732,7 +718,7 @@ describe('one job at a time', () => {
expect(h.deps.ensureRuntimeInstalled).toHaveBeenCalledTimes(1);
});
it('an install of the model that is in use, at its context, is 409 already-installed', async () => {
it('install while a model is installed is 409 already-installed: remove it first', async () => {
const h = setup({ state: INSTALLED });
expect(await h.service.install(REQUEST, ADMIN)).toEqual({
ok: false,
@@ -1087,11 +1073,7 @@ describe('boot with a model installed', () => {
// The pid of the child that runs now replaces the stored one.
expect(h.store.get().runtime?.pid).toBe(5000);
const view = await h.service.view();
expect(view).toMatchObject({
state: 'installed',
running: true,
actions: ['start-session', 'stop', 'install', 'remove'],
});
expect(view).toMatchObject({ state: 'installed', running: true, actions: ['start-session', 'stop', 'remove'] });
expect(view).not.toHaveProperty('error');
});
@@ -1125,7 +1107,7 @@ describe('boot with a model installed', () => {
await h.service.boot();
expect(h.supervisor.start).not.toHaveBeenCalled();
const view = await h.service.view();
expect(view).toMatchObject({ state: 'installed', running: false, actions: ['start', 'install', 'remove'] });
expect(view).toMatchObject({ state: 'installed', running: false, actions: ['start', 'remove'] });
// A stopped runtime is not an error.
expect(view).not.toHaveProperty('error');
});
@@ -1142,7 +1124,7 @@ describe('boot with a model installed', () => {
message: { text: 'The model on this Mac changed since it was installed. Remove it and install it again.' },
next: 'remove',
});
expect(view.actions).toEqual(['stop', 'install', 'remove']);
expect(view.actions).toEqual(['stop', 'remove']);
expect(h.store.get().state).toBe('installed');
expect(h.store.get()).not.toHaveProperty('error');
});
@@ -1158,7 +1140,7 @@ describe('boot with a model installed', () => {
message: { text: 'Port {port} is in use by another program.', vars: { port: '11435' } },
next: 'other-port',
});
expect(view.actions).toEqual(['start', 'install', 'remove']);
expect(view.actions).toEqual(['start', 'remove']);
});
it('never throws, whatever the runtime does', async () => {
@@ -1554,7 +1536,7 @@ describe('launchRefusal: the digest and the runtime decide whether a session sta
'The model on this Mac changed since it was installed. Remove it and install it again.'
);
// And the window stops offering a session.
expect((await h.service.view()).actions).toEqual(['stop', 'install', 'remove']);
expect((await h.service.view()).actions).toEqual(['stop', 'remove']);
});
it('a digest with the sha256 prefix some listings add is the same digest', async () => {
@@ -1620,7 +1602,7 @@ describe('launchRefusal: the digest and the runtime decide whether a session sta
it('only the model that was installed and checked may be started on the managed endpoint', async () => {
const h = await installed();
expect(await h.service.launchRefusalMessage('local-ollama', 'someone-else:7b')).toBe(
'Only Qwen3.6 35B-A3B is in use on this Mac. Switch models in the local model window first.'
'Only Qwen3.6 35B-A3B is installed on this Mac. Remove it and install another model first.'
);
});
@@ -1659,7 +1641,7 @@ describe('the runtime verbs', () => {
expect(stopped.ok && stopped.view).toMatchObject({
state: 'installed',
running: false,
actions: ['start', 'install', 'remove'],
actions: ['start', 'remove'],
});
expect(h.supervisor.stop).toHaveBeenCalledTimes(1);
expect(h.store.get().runtime?.enabled).toBe(false);
@@ -1670,10 +1652,7 @@ describe('the runtime verbs', () => {
expect(next.supervisor.start).not.toHaveBeenCalled();
const started = await h.service.runtime({ action: 'start' });
expect(started.ok && started.view).toMatchObject({
running: true,
actions: ['start-session', 'stop', 'install', 'remove'],
});
expect(started.ok && started.view).toMatchObject({ running: true, actions: ['start-session', 'stop', 'remove'] });
expect(h.store.get().runtime?.enabled).toBe(true);
});
@@ -1768,13 +1747,10 @@ describe('Remove', () => {
unpackedGb: '0.6',
},
folder: '/data/local-model',
library: [],
// Measured again after the Remove: the store is empty here.
store: { bytes: 0, gb: '0.0' },
actions: ['install'],
});
expect(h.store.get()).toMatchObject({ schema: 1, state: 'idle' });
for (const key of ['model', 'cli', 'runtime', 'connection', 'loaded', 'library'])
for (const key of ['model', 'cli', 'runtime', 'connection', 'loaded'])
expect(h.store.get()).not.toHaveProperty(key);
expect(h.deps.audit).toHaveBeenCalledWith({
admin: 'root',
@@ -1962,57 +1938,6 @@ describe('view()', () => {
await h.service.cancel();
});
it('a view whose read began while the job ran shows how the job ended, never paused', async () => {
// The window polls every second, so a read of the state file can be under way while the
// job writes its last state and lets go. That read returns the old `installing`, and
// settling it with no job in this process read it as an interrupted job: the window (and
// the e2e test, under CI load) saw `paused` with Resume for one tick instead of `failed`.
const slow = {
read: undefined as undefined | (() => Promise<LocalModelStateFile>),
hold: undefined as undefined | Promise<void>,
entered: undefined as undefined | (() => void),
};
const h = setup({
overrides: {
readState: async () => {
const snapshot = await slow.read!();
const hold = slow.hold;
slow.hold = undefined;
if (hold) {
slow.entered?.();
await hold;
}
return snapshot;
},
},
});
slow.read = h.store.readState;
let failVerify!: () => void;
const verifying = new Promise<void>((resolve) => (failVerify = resolve));
h.deps.verifyModel.mockImplementationOnce(async () => {
await verifying;
return { ok: false, code: 'no-tool-call' } as never;
});
await h.service.install(REQUEST, ADMIN);
await vi.waitFor(() => expect(h.deps.verifyModel).toHaveBeenCalled());
// A view starts reading while the job is still in the verify step...
let releaseRead!: () => void;
slow.hold = new Promise<void>((resolve) => (releaseRead = resolve));
const entered = new Promise<void>((resolve) => (slow.entered = resolve));
const viewing = h.service.view();
await entered;
// ...the job fails, writes it and ends while that read is still out...
failVerify();
await h.service.idle();
expect(h.store.get().state).toBe('failed');
// ...and only then does the read come back, with the file as it was before.
releaseRead();
const view = await viewing;
expect(view).toMatchObject({ state: 'failed', step: 'verify', error: { code: 'no-tool-call' } });
});
it('names the model to try next only when the catalogue still has it', async () => {
const state: LocalModelStateFile = {
...jobAt('verify', 'paused'),
@@ -2366,7 +2291,7 @@ describe('an installed model whose runtime does not start', () => {
h.world.startResult = { ok: false, code: 'runtime-start-failed', detail: 'spawn ENOENT' };
await h.service.boot();
const view = await h.service.view();
expect(view).toMatchObject({ state: 'installed', running: false, actions: ['start', 'install', 'remove'] });
expect(view).toMatchObject({ state: 'installed', running: false, actions: ['start', 'remove'] });
expect(view.error).toEqual({
code: 'runtime-start-failed',
message: { text: 'The runtime did not start on this Mac.' },
@@ -2420,11 +2345,7 @@ describe('a later build with another runtime pin', () => {
// The stored version stays what runs, so the next start finds it again.
expect(h.store.get().runtime?.version).toBe(OLD);
const view = await h.service.view();
expect(view).toMatchObject({
state: 'installed',
running: true,
actions: ['start-session', 'stop', 'install', 'remove'],
});
expect(view).toMatchObject({ state: 'installed', running: true, actions: ['start-session', 'stop', 'remove'] });
expect(view.error).toBeUndefined();
// A NEW install would still download the pinned build.
expect(view.runtime).toMatchObject({ version: OLLAMA_RUNTIME_PIN.version, installed: false });
@@ -2667,688 +2588,3 @@ describe('Stop and "Use another port" while live sessions use the model', () =>
expect(h.store.get().runtime?.port).toBe(11435);
});
});
// ---------- The library ----------
const GPT = { id: 'gpt-oss:20b', label: 'GPT-OSS 20B', context: 131072 };
const GPT_BYTES = 13_793_441_427;
const QWEN_ENTRY = {
id: 'qwen3.6:35b-a3b',
label: 'Qwen3.6 35B-A3B',
context: 131072,
digest: DIGEST,
bytes: PULL_BYTES,
cli: { id: 'opencode', label: 'OpenCode' },
};
const GPT_ENTRY = {
id: GPT.id,
label: GPT.label,
context: 65536,
digest: DIGEST,
bytes: GPT_BYTES,
cli: { id: 'opencode', label: 'OpenCode' },
};
/** Two checked models, the Qwen one in use. */
const TWO_MODELS: LocalModelStateFile = { ...INSTALLED, library: [QWEN_ENTRY, GPT_ENTRY] };
const BOTH_TAGS = [
{ name: QWEN_ENTRY.id, size: PULL_BYTES, digest: DIGEST },
{ name: GPT_ENTRY.id, size: GPT_BYTES, digest: DIGEST },
];
const ADD_GPT: LocalModelInstallRequest = {
modelId: GPT.id,
cli: 'opencode',
context: GPT.context,
acknowledgedBytes: GPT_BYTES + RUNTIME_BYTES,
};
describe('the first install starts the library', () => {
it('the model that passed its check is the one checked model, with its digest, size and coding agent', async () => {
const h = setup();
await installAndWait(h.service);
expect(h.store.get().library).toEqual([{ ...QWEN_ENTRY, verifiedAt: new Date(1_000_000).toISOString() }]);
const view = await h.service.view();
expect(view.library).toEqual([
{
id: QWEN_ENTRY.id,
label: QWEN_ENTRY.label,
context: 131072,
sizeBytes: PULL_BYTES,
sizeGb: '23.1',
active: true,
verified: true,
status: 'ready',
actions: [],
},
]);
});
it('the entry is written when the check passes, before the coding agent is installed', async () => {
const h = setup({
overrides: {
runInstallCommand: vi.fn(async () => ({ code: 1, output: 'no network\n', timedOut: false, aborted: false })),
},
});
await installAndWait(h.service);
expect(h.store.get()).toMatchObject({ state: 'failed', step: 'agent', library: [{ id: QWEN_ENTRY.id }] });
// It is the model of the stopped job: Resume goes on with it, so no switch is offered.
const view = await h.service.view();
expect(view.library.map((row) => row.actions)).toEqual([[]]);
expect(view.actions).toEqual(['resume', 'install', 'remove']);
});
it('a size the runtime does not report falls back to what the pull counted', async () => {
const h = setup();
h.world.tags = [];
await installAndWait(h.service);
expect(h.store.get().library?.[0].bytes).toBe(PULL_BYTES);
});
});
describe('adding a model to an installed one', () => {
it('downloads, checks and connects it, and keeps the earlier model in the library', async () => {
const h = setup({ state: INSTALLED, runtimeOnDisk: true, agentInstalled: true });
await h.service.boot();
h.calls.length = 0;
h.world.tags = BOTH_TAGS;
const started = await h.service.install(ADD_GPT, ADMIN);
expect(started.ok && started.view.state).toBe('installing');
await h.service.idle();
expect(h.calls).toEqual([
'ensureRuntimeInstalled',
'supervisor.start',
'supervisor.start',
'pullModel',
'supervisor.start',
'verifyModel',
'supervisor.start',
'connectLocalModel',
]);
// Nothing of the earlier model was deleted: it was checked, and it stays.
expect(h.deps.deleteModel).not.toHaveBeenCalled();
expect(h.deps.removePartialModelBlobs).not.toHaveBeenCalled();
// The runtime is started for the new model's context.
expect(h.supervisor.start).toHaveBeenLastCalledWith(
expect.objectContaining({ port: 11435, contextLength: 131072 })
);
// The one endpoint record is rewritten for the new model; it stays ours.
expect(h.deps.connectLocalModel).toHaveBeenCalledWith({
port: 11435,
modelId: GPT.id,
context: GPT.context,
ours: true,
flagWasEnabled: false,
});
expect(h.store.get()).toMatchObject({
state: 'installed',
model: { id: GPT.id, context: GPT.context, digest: DIGEST },
connection: { endpointId: 'local-ollama', flagWasEnabled: false },
library: [QWEN_ENTRY, { id: GPT.id, context: GPT.context, digest: DIGEST, bytes: GPT_BYTES }],
});
expect(h.store.get()).not.toHaveProperty('replacedModelId');
const view = await h.service.view();
expect(view.model).toEqual({ id: GPT.id, label: GPT.label });
expect(view.library.map((row) => [row.id, row.active, row.status, row.actions])).toEqual([
[GPT.id, true, 'ready', []],
[QWEN_ENTRY.id, false, 'ready', ['use', 'delete']],
]);
expect(view.actions).toEqual(['start-session', 'stop', 'install', 'use', 'delete-model', 'remove']);
// Only the model in use may be launched.
expect(await h.service.launchRefusalMessage('local-ollama', GPT.id)).toBeUndefined();
expect(await h.service.launchRefusalMessage('local-ollama', QWEN_ENTRY.id)).toBe(
'Only GPT-OSS 20B is in use on this Mac. Switch models in the local model window first.'
);
});
it('is refused while live sessions use the model, before anything is asked or started', async () => {
const h = setup({ state: INSTALLED, runtimeOnDisk: true, agentInstalled: true });
await h.service.boot();
h.calls.length = 0;
const refused = await h.service.install(ADD_GPT, ADMIN, ['w1-app', 'w2-api']);
expect(refused).toMatchObject({
ok: false,
status: 409,
conflict: {
reason: 'sessions-open',
sessions: ['w1-app', 'w2-api'],
message: { text: LOCAL_MODEL_INSTALL_LABELS.conflictSessionsOpen, vars: { sessions: 'w1-app, w2-api' } },
},
});
expect(h.calls).toEqual([]);
expect(h.deps.readHardware).not.toHaveBeenCalled();
expect(h.store.get().state).toBe('installed');
});
it('a first install is not held up by sessions elsewhere: no connection of ours exists yet', async () => {
const h = setup();
const started = await h.service.install(REQUEST, ADMIN, ['w1-app']);
expect(started.ok).toBe(true);
await h.service.cancel();
});
it('the model in use at ANOTHER context is an install like any other: it is checked again at that context', async () => {
const h = setup({ state: INSTALLED, runtimeOnDisk: true, agentInstalled: true });
h.deps.recommend.mockReturnValue({
...recommendation(true),
pick: candidate('qwen3.6:35b-a3b', 'Qwen3.6 35B-A3B', 65536, PULL_BYTES, true),
});
await h.service.boot();
const started = await h.service.install({ ...REQUEST, context: 65536 }, ADMIN);
expect(started.ok).toBe(true);
await h.service.idle();
expect(h.store.get()).toMatchObject({
state: 'installed',
model: { id: 'qwen3.6:35b-a3b', context: 65536 },
library: [{ id: 'qwen3.6:35b-a3b', context: 65536 }],
});
expect(h.store.get().library).toHaveLength(1);
});
it('a job that stops leaves the earlier model to go back to, and no session until then', async () => {
const h = setup({ state: INSTALLED, runtimeOnDisk: true, agentInstalled: true });
await h.service.boot();
h.world.tags = BOTH_TAGS;
h.deps.verifyModel.mockResolvedValueOnce({ ok: false, code: 'no-tool-call' } as never);
await h.service.install(ADD_GPT, ADMIN);
await h.service.idle();
expect(h.store.get()).toMatchObject({ state: 'failed', step: 'verify', model: { id: GPT.id } });
// The endpoint record still names the earlier model, and nothing may be started on it.
expect(await h.service.launchRefusal('local-ollama')).toEqual({
code: 'job-unfinished',
message: { text: LOCAL_MODEL_INSTALL_LABELS.errJobUnfinished },
next: 'retry',
});
expect(await h.service.launchRefusalMessage('local-ollama', QWEN_ENTRY.id)).toBe(
'The model on this Mac is being changed and is not ready. Finish or undo that in the local model window first.'
);
const view = await h.service.view();
expect(view.actions).toEqual(['resume', 'install', 'use', 'delete-model', 'remove']);
expect(view.library.map((row) => [row.id, row.status, row.actions])).toEqual([
[QWEN_ENTRY.id, 'ready', ['use', 'delete']],
// Downloaded and not checked: the model of the stopped job, so nothing is offered on it.
[GPT.id, 'unverified', []],
]);
// The way back. The model that never passed its check goes: it is what the job left behind.
h.calls.length = 0;
const back = await h.service.use({ modelId: QWEN_ENTRY.id }, ADMIN);
expect(back.ok).toBe(true);
await h.service.idle();
expect(h.deps.deleteModel).toHaveBeenCalledWith(expect.anything(), GPT.id, expect.anything());
expect(h.calls).toContain('removePartialModelBlobs');
expect(h.store.get()).toMatchObject({ state: 'installed', model: { id: QWEN_ENTRY.id, digest: DIGEST } });
expect(h.store.get()).not.toHaveProperty('replacedModelId');
expect(h.store.get()).not.toHaveProperty('error');
expect(await h.service.launchRefusalMessage('local-ollama', QWEN_ENTRY.id)).toBeUndefined();
});
});
describe('use: the switch to a checked model', () => {
it('loads and checks it again, connects it, and downloads nothing', async () => {
const h = setup({ state: TWO_MODELS, runtimeOnDisk: true, agentInstalled: true });
await h.service.boot();
h.calls.length = 0;
h.world.tags = BOTH_TAGS;
const started = await h.service.use({ modelId: GPT.id }, ADMIN);
expect(started.ok && started.view).toMatchObject({
state: 'installing',
step: 'verify',
steps: ['verify', 'connect'],
model: { id: GPT.id, label: GPT.label },
context: 65536,
});
await h.service.idle();
expect(h.calls).toEqual([
'recheckCliInstalled',
'supervisor.start',
'verifyModel',
'supervisor.start',
'connectLocalModel',
]);
expect(h.deps.pullModel).not.toHaveBeenCalled();
expect(h.deps.ensureRuntimeInstalled).not.toHaveBeenCalled();
expect(h.deps.deleteModel).not.toHaveBeenCalled();
// The runtime is restarted for the context the model was checked with.
expect(h.supervisor.start).toHaveBeenLastCalledWith(expect.objectContaining({ contextLength: 65536 }));
expect(h.deps.verifyModel).toHaveBeenCalledWith(expect.anything(), GPT.id, 65536, expect.anything());
expect(h.deps.connectLocalModel).toHaveBeenCalledWith(
expect.objectContaining({ modelId: GPT.id, context: 65536, ours: true })
);
expect(h.store.get()).toMatchObject({
state: 'installed',
model: { id: GPT.id, context: 65536, digest: DIGEST },
cli: { id: 'opencode', label: 'OpenCode' },
library: [QWEN_ENTRY, { id: GPT.id, digest: DIGEST }],
});
expect(h.deps.audit).toHaveBeenCalledWith({
admin: 'root',
action: 'local_model_use',
target: GPT.id,
ip: '127.0.0.1',
detail: { cli: 'opencode', context: 65536 },
});
});
it('is refused for a model that is not one of the checked ones, and for the one in use', async () => {
const h = setup({ state: TWO_MODELS, runtimeOnDisk: true, agentInstalled: true });
await h.service.boot();
h.calls.length = 0;
expect(await h.service.use({ modelId: 'someone-else:7b' }, ADMIN)).toEqual({
ok: false,
status: 400,
message: { text: LOCAL_MODEL_INSTALL_LABELS.refusedNotInLibrary },
});
expect(await h.service.use({ modelId: QWEN_ENTRY.id }, ADMIN)).toMatchObject({
ok: false,
status: 409,
conflict: { reason: 'already-installed' },
});
expect(h.calls).toEqual([]);
expect(h.store.get().state).toBe('installed');
});
it('is refused while live sessions use the model', async () => {
const h = setup({ state: TWO_MODELS, runtimeOnDisk: true, agentInstalled: true });
await h.service.boot();
h.calls.length = 0;
expect(await h.service.use({ modelId: GPT.id }, ADMIN, ['w1-app'])).toMatchObject({
ok: false,
status: 409,
conflict: { reason: 'sessions-open', sessions: ['w1-app'] },
});
expect(h.calls).toEqual([]);
});
it('is 409 wrong-state where there is nothing to switch: idle', async () => {
const h = setup();
expect(await h.service.use({ modelId: GPT.id }, ADMIN)).toMatchObject({
ok: false,
status: 409,
conflict: { reason: 'wrong-state' },
});
});
it('a model whose digest is no longer the stored one is not loaded: model-changed, and the way back stays', async () => {
const h = setup({ state: TWO_MODELS, runtimeOnDisk: true, agentInstalled: true });
await h.service.boot();
h.world.tags = [BOTH_TAGS[0], { ...BOTH_TAGS[1], digest: 'ffff00000000' }];
h.deps.readModelDigest.mockImplementation(async (_target: unknown, id: string) =>
id === GPT.id ? 'ffff00000000' : DIGEST
);
h.calls.length = 0;
await h.service.use({ modelId: GPT.id }, ADMIN);
await h.service.idle();
expect(h.deps.verifyModel).not.toHaveBeenCalled();
expect(h.deps.connectLocalModel).not.toHaveBeenCalled();
const view = await h.service.view();
expect(view.state).toBe('failed');
expect(view.error).toMatchObject({
code: 'model-changed',
detail: 'The runtime reports another digest for the model.',
});
expect(view.library.map((row) => [row.id, row.status, row.actions])).toEqual([
[QWEN_ENTRY.id, 'ready', ['use', 'delete']],
[GPT.id, 'changed', []],
]);
// The changed model stays a library entry until the person deletes it; it is never deleted as a leftover.
await h.service.use({ modelId: QWEN_ENTRY.id }, ADMIN);
await h.service.idle();
expect(h.deps.deleteModel).not.toHaveBeenCalled();
expect(h.store.get()).toMatchObject({ state: 'installed', model: { id: QWEN_ENTRY.id } });
});
it('a model the runtime no longer lists is model-changed too, said as that', async () => {
const h = setup({ state: TWO_MODELS, runtimeOnDisk: true, agentInstalled: true });
await h.service.boot();
h.deps.readModelDigest.mockImplementation(async (_target: unknown, id: string) =>
id === GPT.id ? undefined : DIGEST
);
await h.service.use({ modelId: GPT.id }, ADMIN);
await h.service.idle();
expect((await h.service.view()).error).toMatchObject({
code: 'model-changed',
detail: 'The runtime no longer lists the model.',
});
});
it('takes the coding agent the model was checked with when it is installed, else the one of the state', async () => {
const pi = { id: 'pi', label: 'Pi' };
const state: LocalModelStateFile = { ...TWO_MODELS, library: [QWEN_ENTRY, { ...GPT_ENTRY, cli: pi }] };
const withPi = setup({ state, runtimeOnDisk: true });
withPi.deps.recheckCliInstalled.mockImplementation(async () => true);
await withPi.service.boot();
await withPi.service.use({ modelId: GPT.id }, ADMIN);
await withPi.service.idle();
expect(withPi.store.get().cli).toEqual(pi);
const withoutPi = setup({ state, runtimeOnDisk: true });
withoutPi.deps.recheckCliInstalled.mockImplementation((async (id: string) => id === 'opencode') as never);
await withoutPi.service.boot();
await withoutPi.service.use({ modelId: GPT.id }, ADMIN);
await withoutPi.service.idle();
expect(withoutPi.store.get().cli).toEqual({ id: 'opencode', label: 'OpenCode' });
});
it('with no coding agent of the two installed it is refused, in one sentence, and nothing starts', async () => {
const h = setup({ state: TWO_MODELS, runtimeOnDisk: true, agentInstalled: false });
await h.service.boot();
expect(await h.service.use({ modelId: GPT.id }, ADMIN)).toEqual({
ok: false,
status: 400,
message: { text: LOCAL_MODEL_INSTALL_LABELS.refusedAgentMissing, vars: { cli: 'OpenCode' } },
});
expect(h.store.get().state).toBe('installed');
});
it('a switch or a new model from a stopped model starts the runtime and undoes the Stop', async () => {
const stopped: LocalModelStateFile = { ...TWO_MODELS, runtime: { ...INSTALLED.runtime!, enabled: false } };
const switched = setup({ state: stopped, runtimeOnDisk: true, agentInstalled: true });
await switched.service.boot();
expect(switched.supervisor.start).not.toHaveBeenCalled();
await switched.service.use({ modelId: GPT.id }, ADMIN);
await switched.service.idle();
// The model is in use and running now, so it starts with the server again, like after Start.
expect(switched.store.get()).toMatchObject({ state: 'installed', runtime: { enabled: true } });
expect((await switched.service.view()).running).toBe(true);
const added = setup({ state: { ...INSTALLED, runtime: { ...INSTALLED.runtime!, enabled: false } } });
added.world.runtimeOnDisk = true;
added.world.agentInstalled = true;
await added.service.boot();
await added.service.install(ADD_GPT, ADMIN);
await added.service.idle();
expect(added.store.get()).toMatchObject({ state: 'installed', model: { id: GPT.id }, runtime: { enabled: true } });
// A switch that was refused changed nothing: the model stays stopped.
const refused = setup({ state: stopped, runtimeOnDisk: true, agentInstalled: true });
await refused.service.use({ modelId: 'someone-else:7b' }, ADMIN);
expect(refused.store.get().runtime?.enabled).toBe(false);
});
it('Cancel pauses a switch like any job, and Resume goes on with it', async () => {
const h = setup({
state: TWO_MODELS,
runtimeOnDisk: true,
agentInstalled: true,
overrides: {
verifyModel: vi
.fn()
.mockImplementationOnce(async (_t: unknown, _id: string, _c: number, signal?: AbortSignal) => {
await untilAborted(signal);
return { ok: false as const, code: 'cancelled' as const };
})
.mockResolvedValue({
ok: true as const,
loaded: { context: 65536, sizeBytes: 15_000_000_000, sizeVramBytes: 15_000_000_000 },
}),
},
});
await h.service.boot();
await h.service.use({ modelId: GPT.id }, ADMIN);
await vi.waitFor(() => expect(h.deps.verifyModel).toHaveBeenCalled());
const paused = await h.service.cancel();
expect(paused.ok && paused.view).toMatchObject({ state: 'paused', step: 'verify', steps: ['verify', 'connect'] });
await h.service.resume({}, ADMIN);
await h.service.idle();
expect(h.store.get()).toMatchObject({ state: 'installed', model: { id: GPT.id } });
});
});
describe('deleteLibraryModel: one model out of the store', () => {
async function twoModelsRunning() {
const h = setup({ state: TWO_MODELS, runtimeOnDisk: true, agentInstalled: true });
await h.service.boot();
h.world.tags = [...BOTH_TAGS];
h.deps.deleteModel.mockImplementation(async (_target: unknown, id: string) => {
h.calls.push('deleteModel');
h.world.tags = (h.world.tags ?? []).filter((tag) => tag.name !== id);
return true;
});
h.calls.length = 0;
return h;
}
it('deletes it through the runtime, drops its entry and writes the audit line', async () => {
const h = await twoModelsRunning();
const result = await h.service.deleteLibraryModel({ modelId: GPT.id }, ADMIN);
expect(h.deps.deleteModel).toHaveBeenCalledTimes(1);
expect(h.deps.deleteModel).toHaveBeenCalledWith({ baseUrl: 'http://127.0.0.1:11435' }, GPT.id, expect.anything());
expect(h.store.get().library).toEqual([QWEN_ENTRY]);
expect(h.store.get().state).toBe('installed');
// The answer is the view after the delete: the runtime was asked again.
expect(result.ok && result.view.library.map((row) => row.id)).toEqual([QWEN_ENTRY.id]);
expect(h.deps.audit).toHaveBeenCalledWith({
admin: 'root',
action: 'local_model_delete',
target: GPT.id,
ip: '127.0.0.1',
detail: { verified: true, bytes: GPT_BYTES },
});
// Nothing else of the feature was touched.
expect(h.calls).toEqual(['deleteModel']);
});
it('refuses the model in use: switch first, or remove', async () => {
const h = await twoModelsRunning();
expect(await h.service.deleteLibraryModel({ modelId: QWEN_ENTRY.id }, ADMIN)).toEqual({
ok: false,
status: 409,
conflict: {
reason: 'model-active',
message: { text: LOCAL_MODEL_INSTALL_LABELS.conflictModelActive, vars: { model: 'Qwen3.6 35B-A3B' } },
},
});
expect(h.deps.deleteModel).not.toHaveBeenCalled();
});
it('refuses while the runtime is down or does not answer: only the runtime deletes a model', async () => {
const stopped = setup({ state: TWO_MODELS });
expect(await stopped.service.deleteLibraryModel({ modelId: GPT.id }, ADMIN)).toMatchObject({
ok: false,
status: 409,
conflict: { reason: 'runtime-not-running', message: { text: LOCAL_MODEL_INSTALL_LABELS.errRuntimeNotRunning } },
});
const silent = await twoModelsRunning();
silent.deps.getRuntimeVersion.mockResolvedValue(undefined);
expect(await silent.service.deleteLibraryModel({ modelId: GPT.id }, ADMIN)).toMatchObject({
ok: false,
status: 409,
conflict: { reason: 'runtime-not-running' },
});
expect(silent.deps.deleteModel).not.toHaveBeenCalled();
expect(silent.store.get().library).toHaveLength(2);
});
it('refuses a name that is neither a checked model nor one the runtime lists, and anything that is not name:tag', async () => {
const h = await twoModelsRunning();
const refused = { ok: false, status: 400, message: { text: LOCAL_MODEL_INSTALL_LABELS.refusedNotOnThisMac } };
expect(await h.service.deleteLibraryModel({ modelId: 'someone-else:7b' }, ADMIN)).toEqual(refused);
for (const modelId of ['../../etc/passwd', '/Users/me/models', 'hf.co/someone/model:Q4', 'NoTag', '']) {
expect(await h.service.deleteLibraryModel({ modelId }, ADMIN)).toEqual(refused);
}
expect(h.deps.deleteModel).not.toHaveBeenCalled();
});
it('is 409 wrong-state in idle: there is no runtime to ask', async () => {
const h = setup();
expect(await h.service.deleteLibraryModel({ modelId: GPT.id }, ADMIN)).toMatchObject({
ok: false,
status: 409,
conflict: { reason: 'wrong-state' },
});
});
it('a runtime that does not delete it: said as that, and the entry stays', async () => {
const h = await twoModelsRunning();
h.deps.deleteModel.mockResolvedValue(false);
expect(await h.service.deleteLibraryModel({ modelId: GPT.id }, ADMIN)).toEqual({
ok: false,
status: 500,
message: { text: LOCAL_MODEL_INSTALL_LABELS.deleteFailed, vars: { model: 'GPT-OSS 20B' } },
});
expect(h.store.get().library).toEqual([QWEN_ENTRY, GPT_ENTRY]);
expect(h.deps.audit).not.toHaveBeenCalled();
});
it('a model the runtime lists and Codeman never checked can be deleted too', async () => {
const h = await twoModelsRunning();
h.world.tags = [...BOTH_TAGS, { name: 'leftover:9b', size: 6_000_000_000, digest: 'aa' }];
const before = await h.service.view();
expect(before.library.find((row) => row.id === 'leftover:9b')).toMatchObject({
status: 'unverified',
sizeGb: '6.0',
actions: ['delete'],
});
const result = await h.service.deleteLibraryModel({ modelId: 'leftover:9b' }, ADMIN);
expect(result.ok && result.view.library.map((row) => row.id)).toEqual([QWEN_ENTRY.id, GPT.id]);
expect(h.store.get().library).toEqual([QWEN_ENTRY, GPT_ENTRY]);
expect(h.deps.audit).toHaveBeenCalledWith(
expect.objectContaining({ action: 'local_model_delete', detail: { verified: false, bytes: 6_000_000_000 } })
);
});
it('a checked model the runtime no longer lists: its entry is dropped once the runtime says it is gone', async () => {
const h = await twoModelsRunning();
h.world.tags = [BOTH_TAGS[0]];
const result = await h.service.deleteLibraryModel({ modelId: GPT.id }, ADMIN);
expect(result.ok).toBe(true);
// Asked of the runtime all the same: its "not found" is what says the model is gone.
expect(h.deps.deleteModel).toHaveBeenCalledWith(expect.anything(), GPT.id, expect.anything());
expect(h.store.get().library).toEqual([QWEN_ENTRY]);
});
it('works from a stopped job too, for any model but the one of that job', async () => {
const state: LocalModelStateFile = {
...jobAt('verify', 'paused'),
model: { ...GPT },
library: [QWEN_ENTRY],
connection: { endpointId: 'local-ollama', flagWasEnabled: false },
};
const h = setup({ state, runtimeOnDisk: true });
h.world.running = true;
h.world.tags = BOTH_TAGS;
expect(await h.service.deleteLibraryModel({ modelId: GPT.id }, ADMIN)).toMatchObject({
ok: false,
conflict: { reason: 'model-active' },
});
const result = await h.service.deleteLibraryModel({ modelId: QWEN_ENTRY.id }, ADMIN);
expect(result.ok).toBe(true);
expect(h.store.get().library).toEqual([]);
expect(h.store.get().state).toBe('paused');
});
});
describe('the model store in idle: what an earlier Remove kept', () => {
it('shows what it takes, offers Remove, and Remove with the models frees it', async () => {
const h = setup();
h.world.storeBytes = PULL_BYTES;
const view = await h.service.view();
expect(view).toMatchObject({
state: 'idle',
library: [],
store: { bytes: PULL_BYTES, gb: '23.1' },
actions: ['install', 'remove'],
});
h.deps.removeLocalModelFiles.mockImplementation(async () => {
h.calls.push('removeLocalModelFiles');
h.world.storeBytes = 0;
});
const removed = await h.service.remove({ removeModel: true }, [], ADMIN);
expect(h.deps.removeLocalModelFiles).toHaveBeenCalledWith({ models: true, paths: PATHS });
expect(removed.ok && removed.view).toMatchObject({
state: 'idle',
store: { bytes: 0, gb: '0.0' },
actions: ['install'],
});
});
it('Remove that keeps the models leaves them there: the next view says how much', async () => {
const h = setup({ state: TWO_MODELS, runtimeOnDisk: true });
h.world.storeBytes = PULL_BYTES + GPT_BYTES;
const removed = await h.service.remove({}, [], ADMIN);
expect(removed.ok && removed.view).toMatchObject({
state: 'idle',
library: [],
store: { bytes: PULL_BYTES + GPT_BYTES, gb: '36.9' },
actions: ['install', 'remove'],
});
expect(h.store.get()).not.toHaveProperty('library');
});
});
describe('the view does not ask the runtime or the disk on every poll', () => {
it('reads the list and the store once, then again only when they are stale', async () => {
const h = setup({ state: TWO_MODELS, runtimeOnDisk: true });
await h.service.boot();
h.world.tags = BOTH_TAGS;
h.deps.listTags.mockClear();
for (let i = 0; i < 5; i++) await h.service.view();
expect(h.deps.listTags).toHaveBeenCalledTimes(1);
expect(h.deps.measureModelStore).toHaveBeenCalledTimes(1);
// Five seconds on: the list is read again, in the background. The view does not wait for
// a runtime that is slow to answer: it is built from the list it has.
h.world.clock += 5_000;
let answer: (tags: typeof BOTH_TAGS) => void = () => {};
h.deps.listTags.mockImplementationOnce(() => new Promise((resolve) => (answer = resolve)));
const stale = await h.service.view();
expect(stale.library.map((row) => row.status)).toEqual(['ready', 'ready']);
await vi.waitFor(() => expect(h.deps.listTags).toHaveBeenCalledTimes(2));
// A second poll while that read is out starts no second one.
await h.service.view();
expect(h.deps.listTags).toHaveBeenCalledTimes(2);
answer([BOTH_TAGS[0]]);
await vi.waitFor(async () =>
expect((await h.service.view()).library.map((row) => row.status)).toEqual(['ready', 'missing'])
);
expect(h.deps.measureModelStore).toHaveBeenCalledTimes(1);
// Thirty seconds on: the store is measured again.
h.world.clock += 30_000;
await h.service.view();
await vi.waitFor(() => expect(h.deps.measureModelStore).toHaveBeenCalledTimes(2));
});
it('a runtime that does not answer keeps the last list: no model reads as missing for a moment', async () => {
const h = setup({ state: TWO_MODELS, runtimeOnDisk: true });
await h.service.boot();
h.world.tags = BOTH_TAGS;
await h.service.view();
h.deps.getRuntimeVersion.mockResolvedValue(undefined);
h.deps.listTags.mockClear();
h.world.clock += 5_000;
await h.service.view();
await new Promise((resolve) => setTimeout(resolve, 20));
expect(h.deps.listTags).not.toHaveBeenCalled();
expect((await h.service.view()).library.map((row) => row.status)).toEqual(['ready', 'ready']);
});
it('a stopped runtime is not asked at all, and offers no delete', async () => {
const h = setup({ state: { ...TWO_MODELS, runtime: { ...INSTALLED.runtime!, enabled: false } } });
await h.service.boot();
const view = await h.service.view();
expect(h.deps.listTags).not.toHaveBeenCalled();
expect(view.library.map((row) => [row.id, row.sizeGb, row.actions])).toEqual([
[QWEN_ENTRY.id, '23.1', []],
[GPT.id, '13.8', ['use']],
]);
});
it('a listing or a measurement that fails is not an error of the view', async () => {
const h = setup({ state: TWO_MODELS, runtimeOnDisk: true });
await h.service.boot();
h.deps.listTags.mockRejectedValue(new Error('socket hang up'));
h.deps.measureModelStore.mockRejectedValue(new Error('EIO'));
const view = await h.service.view();
expect(view.state).toBe('installed');
expect(view).not.toHaveProperty('store');
expect(view.library.map((row) => row.id)).toEqual([QWEN_ENTRY.id, GPT.id]);
});
});
-54
View File
@@ -37,7 +37,6 @@ import {
OLLAMA_RUNTIME_PIN,
parseInferenceComputeTotal,
removeLocalModelFiles,
measureModelStore,
removePartialModelBlobs,
RUNTIME_CTX_CHECKPOINTS,
RUNTIME_DOWNLOAD_RETRIES,
@@ -1104,56 +1103,3 @@ describe('removePartialModelBlobs', () => {
expect(existsSync(join(outside, 'sha256-eee-partial'))).toBe(true);
});
});
describe('measureModelStore', () => {
const blob = (name: string): string => join(paths.modelsDir, 'blobs', name);
it('is the sizes of the files in the blob folder, a download that was interrupted included', async () => {
await mkdir(join(paths.modelsDir, 'blobs'), { recursive: true });
await mkdir(join(paths.modelsDir, 'manifests', 'registry.ollama.ai'), { recursive: true });
await writeFile(blob('sha256-aaa'), 'x'.repeat(1000));
await writeFile(blob('sha256-bbb'), 'y'.repeat(250));
await writeFile(blob('sha256-ccc-partial'), 'z'.repeat(40));
// The manifests are a few kilobytes beside gigabytes of layers: left out.
await writeFile(join(paths.modelsDir, 'manifests', 'registry.ollama.ai', 'latest'), '{"layers":[]}');
expect(await measureModelStore(paths)).toBe(1290);
});
it('is 0 without a model store, without a blob folder and for an empty one, and never throws', async () => {
expect(await measureModelStore(paths)).toBe(0);
await mkdir(paths.modelsDir, { recursive: true });
expect(await measureModelStore(paths)).toBe(0);
await mkdir(join(paths.modelsDir, 'blobs'), { recursive: true });
expect(await measureModelStore(paths)).toBe(0);
});
it('reads nothing and changes nothing: the store is as it was afterwards', async () => {
await mkdir(join(paths.modelsDir, 'blobs'), { recursive: true });
await writeFile(blob('sha256-aaa'), 'weights');
await writeFile(blob('sha256-bbb-partial'), 'half');
await measureModelStore(paths);
expect(await readFile(blob('sha256-aaa'), 'utf8')).toBe('weights');
expect(await readFile(blob('sha256-bbb-partial'), 'utf8')).toBe('half');
});
it('counts only regular files directly in the blob folder, and follows no link', async () => {
const outside = await outsideDir();
await writeFile(join(outside, 'huge'), 'h'.repeat(5000));
await mkdir(join(paths.modelsDir, 'blobs', 'a-folder'), { recursive: true });
await writeFile(join(paths.modelsDir, 'blobs', 'a-folder', 'inside'), 'i'.repeat(700));
await symlink(join(outside, 'huge'), blob('sha256-link'));
await writeFile(blob('sha256-aaa'), 'x'.repeat(10));
// The folder's file and the link's target are somebody else's: not counted.
expect(await measureModelStore(paths)).toBe(10);
});
it('a blob folder, a model store or a root that is a symlink counts as empty', async () => {
const outside = await outsideDir();
await writeFile(join(outside, 'sha256-eee'), 'e'.repeat(900));
await mkdir(paths.modelsDir, { recursive: true });
await symlink(outside, join(paths.modelsDir, 'blobs'));
expect(await measureModelStore(paths)).toBe(0);
// A path that is not inside the local model folder is refused before anything is read.
expect(await measureModelStore({ ...paths, modelsDir: outside })).toBe(0);
});
});
-83
View File
@@ -196,89 +196,6 @@ describe('parseLocalModelState', () => {
});
});
describe('parseLocalModelState: the library', () => {
const QWEN = {
id: 'qwen3.6:35b-a3b',
label: 'Qwen3.6 35B-A3B',
context: 131072,
digest: '0ce5ff81f9ffdf89dd8192ca7b5d34cefd8996a875b3aaaaafaf5c4813569eba',
bytes: 23_105_798_956,
cli: { id: 'opencode', label: 'OpenCode' },
verifiedAt: '2026-11-10T09:00:00.000Z',
};
const MIMO = { id: 'mimo-v2.6:9b-q4_k_m', label: 'MiMo V2.6 9B', context: 65536, digest: 'abc123' };
const read = (state: unknown) => parseLocalModelState(JSON.stringify(state));
it('reads back the checked models of an installed state and of a job, in their order', () => {
expect(read({ ...INSTALLED, library: [QWEN, MIMO] })).toEqual({ ...INSTALLED, library: [QWEN, MIMO] });
expect(read({ ...PAUSED, library: [MIMO] })).toEqual({ ...PAUSED, library: [MIMO] });
expect(read({ ...FAILED, library: [MIMO, QWEN] }).library).toEqual([MIMO, QWEN]);
});
it('a file an earlier build wrote has no list, and reads back without one', () => {
expect(read(INSTALLED)).toEqual(INSTALLED);
expect(read(INSTALLED)).not.toHaveProperty('library');
expect(read(PAUSED)).not.toHaveProperty('library');
});
it('an empty list stays an empty list: every checked model was deleted', () => {
expect(read({ ...PAUSED, library: [] }).library).toEqual([]);
});
it('a list that is not a list is dropped, the state is kept', () => {
for (const library of ['qwen', 7, { 0: QWEN }, null, true]) {
const state = read({ ...INSTALLED, library });
expect(state.state).toBe('installed');
expect(state).not.toHaveProperty('library');
}
});
it('drops an entry that could never be switched to: without its id, label, context or digest', () => {
const library = [
{ ...QWEN, id: '' },
{ ...QWEN, label: 7 },
{ ...QWEN, context: 0 },
{ ...QWEN, context: '131072' },
{ ...QWEN, digest: undefined },
'qwen3.6:35b-a3b',
null,
['qwen3.6:35b-a3b'],
MIMO,
];
expect(read({ ...INSTALLED, library }).library).toEqual([MIMO]);
});
it('keeps one entry per model: the first', () => {
const library = [QWEN, { ...QWEN, context: 65536 }, MIMO];
expect(read({ ...INSTALLED, library }).library).toEqual([QWEN, MIMO]);
});
it('drops what it does not know inside an entry, and a member of the wrong type: an entry holds no path', () => {
const library = [
{
...QWEN,
path: '/Users/me/.codeman/local-model/models',
blobs: ['/etc/passwd'],
bytes: -5,
cli: { id: 'opencode' },
verifiedAt: 7,
},
];
const [entry] = read({ ...INSTALLED, library }).library ?? [];
expect(entry).toEqual({ id: QWEN.id, label: QWEN.label, context: QWEN.context, digest: QWEN.digest });
expect(JSON.stringify(read({ ...INSTALLED, library }))).not.toContain('/');
});
it('reads at most 64 entries of a list, however long it is', () => {
const library = Array.from({ length: 500 }, (_unused, i) => ({ ...MIMO, id: `model-${i}:9b` }));
expect(read({ ...INSTALLED, library }).library).toHaveLength(64);
});
it('an idle file has no list, whatever it says', () => {
expect(read({ schema: 1, state: 'idle', library: [QWEN] })).toEqual(IDLE_LOCAL_MODEL_STATE);
});
});
describe('readLocalModelState', () => {
it('is idle when the file is missing, and never throws', async () => {
expect(existsSync(FILE())).toBe(false);
+3 -170
View File
@@ -11,11 +11,7 @@
* the fill follows the selection;
* - the selected row carries the feature's colour on its reading edge;
* - the welcome button is really gone while it is `hidden`, and the window paints above App
* Settings;
* - the models on this Mac: the sizes on disk end on one edge under their caption, a row's
* buttons end on the list's edge, the sizes do not move when a row's Delete question opens,
* a long list scrolls from its top (it is never cut off above the frame), and under 560px a
* row is two lines with nothing running off the side.
* Settings.
*
* The REAL index.html is served with its scripts taken out and the two shipping stylesheets
* inlined (mobile.css under the media query its `<link>` carries); the real module runs on a
@@ -128,54 +124,10 @@ const STATUS = {
developerTools: 'present',
runtime: { version: '0.40.15', installed: false, downloadBytes: RUNTIME, downloadGb: '0.2', unpackedGb: '0.6' },
folder: '/Users/sam/.codeman/local-model',
library: [],
actions: ['install'],
},
};
const libraryRow = (id: string, label: string, sizeGb: string, over: object = {}) => ({
id,
label,
context: 131072,
sizeBytes: Number(sizeGb) * 1e9,
sizeGb,
active: false,
verified: true,
status: 'ready',
actions: ['use', 'delete'],
...over,
});
/** An installed model with two more models beside it: one checked, one the runtime lists and Codeman never checked. */
const INSTALLED = {
...STATUS,
install: {
...STATUS.install,
state: 'installed',
running: true,
runtime: { version: '0.40.15', installed: true, downloadBytes: 0, downloadGb: '0.0', unpackedGb: '0.6' },
model: { id: 'qwen3.6:35b-a3b', label: 'Qwen3.6 35B-A3B' },
cli: 'opencode',
cliLabel: 'OpenCode',
context: 131072,
port: 11435,
endpointId: 'local-ollama',
loaded: { context: 131072, memoryBytes: 26300000000, memoryGb: '26.3', onGpu: true },
library: [
libraryRow('qwen3.6:35b-a3b', 'Qwen3.6 35B-A3B', '23.1', { active: true, actions: [] }),
libraryRow('qwen3.8:27b-mtp-q4_k_m', 'Qwen3.8 27B', '17.7'),
libraryRow('mimo-v2.6:9b-q4_k_m', 'MiMo V2.6 9B', '6.6', {
context: undefined,
verified: false,
status: 'unverified',
actions: ['delete'],
}),
],
store: { bytes: 47400000000, gb: '47.4' },
actions: ['start-session', 'stop', 'install', 'use', 'delete-model', 'remove'],
},
};
type Box = { left: number; right: number; top: number; bottom: number; width: number; height: number };
/** What the page holds once the module ran on the stand-in class. */
@@ -208,7 +160,7 @@ describe('the local model window (real Chromium)', () => {
});
/** The real page at a width, the module loaded, the answer in, the window closed. */
async function load(width: number, height = 900, answer: object = STATUS) {
async function load(width: number, height = 900) {
p = await browser.newPage({ viewport: { width, height }, deviceScaleFactor: 1 });
// No motion: the bar is at its width as soon as it is painted, so nothing here waits on a clock.
await p.emulateMedia({ reducedMotion: 'reduce' });
@@ -226,7 +178,7 @@ describe('the local model window (real Chromium)', () => {
w.app = new w.CodemanApp();
w.app._api = async () => ({ ok: true, status: 200, json: async () => ({ success: true, data: status }) });
await w.app.loadLocalModel();
}, answer);
}, STATUS);
}
/** Two animation frames: whatever was just written is laid out and painted. */
@@ -391,125 +343,6 @@ describe('the local model window (real Chromium)', () => {
expect(overflow).toBeLessThanOrEqual(0);
});
it('the models on this Mac: sizes on one edge, a row one line, its buttons on the list edge', async () => {
await load(1440, 900, INSTALLED);
await open();
const rows = await boxes('.lm-lib-row');
expect(rows).toHaveLength(3);
const names = await boxes('.lm-lib-name');
const tags = await boxes('.lm-lib-tag');
const sizes = await boxes('.lm-lib-size');
const [caption] = await boxes('.lm-lib-caption-size');
expect(same(sizes.map((box) => box.right))).toBe(true);
expect(same([caption.right, sizes[0].right])).toBe(true);
expect(same(names.map((box) => box.left))).toBe(true);
expect(same(tags.map((box) => box.left))).toBe(true);
// One line a row: the name, the tag, the size and the buttons share its middle.
const middle = (box: Box) => (box.top + box.bottom) / 2;
const actions = await boxes('.lm-lib-row .lm-lib-actions');
for (let i = 0; i < rows.length; i++) {
expect(Math.abs(middle(names[i]) - middle(sizes[i]))).toBeLessThan(3);
expect(Math.abs(middle(tags[i]) - middle(sizes[i]))).toBeLessThan(3);
expect(names[i].right).toBeLessThanOrEqual(tags[i].left + 0.6);
expect(sizes[i].right).toBeLessThanOrEqual(actions[i].left + 0.6);
}
// The last button of a row ends where every line of the window ends: on "Add a model"'s edge.
const rowButtons = await boxes('.lm-lib-row .lm-lib-actions button:last-child');
const [add] = await boxes('.lm-lib-foot button');
expect(same([...rowButtons.map((box) => box.right), add.right])).toBe(true);
// The model in use carries the feature's colour on its reading edge; the others do not.
const paint = await p.evaluate(() => {
const probe = document.body.appendChild(document.createElement('i'));
probe.style.color = 'var(--lm-brand)';
const brand = getComputedStyle(probe).color;
probe.remove();
const edge = (sel: string) => getComputedStyle(document.querySelector(sel)!).borderLeftColor;
return { brand, on: edge('.lm-lib-row-on'), off: edge('.lm-lib-row:not(.lm-lib-row-on)') };
});
expect(paint.on).toBe(paint.brand);
expect(paint.off).not.toBe(paint.brand);
// The screen starts at the top of the frame, right under the header.
const [body] = await boxes('#localModelWindowBody');
const [title] = await boxes('.lm-title');
expect(title.top - body.top).toBeLessThan(40);
});
it('the Delete question opens under its row without moving the sizes', async () => {
await load(1440, 900, INSTALLED);
await open();
const before = await boxes('.lm-lib-size');
await p.click('.lm-lib-row:nth-of-type(3) .lm-lib-actions button:last-child');
await painted();
const [ask] = await boxes('.lm-lib-ask');
const rows = await boxes('.lm-lib-row');
const after = await boxes('.lm-lib-size');
// Sideways nothing moved; the rows after the question moved down by it.
expect(after.map((box) => Math.round(box.right))).toEqual(before.map((box) => Math.round(box.right)));
expect(ask.top).toBeGreaterThanOrEqual(rows[1].bottom - 1);
expect(rows[2].top).toBeGreaterThanOrEqual(ask.bottom - 1);
// The row's own buttons are unseen while it is asked about, and the red button is the question's.
const seen = await p.evaluate(() => ({
away: getComputedStyle(document.querySelector('.lm-lib-actions-away')!).visibility,
red: [...document.querySelectorAll('.local-model-window .wt-btn-danger')].map((el) => el.textContent),
focus: document.activeElement?.textContent,
}));
expect(seen).toEqual({ away: 'hidden', red: ['Delete'], focus: 'Cancel' });
});
it('a long list of models scrolls from its top: the first lines are never cut off above the frame', async () => {
const many = Array.from({ length: 14 }, (_unused, i) => libraryRow(`model-${i}:9b`, `Model ${i}`, '6.6'));
const answer = {
...INSTALLED,
install: { ...INSTALLED.install, library: [...INSTALLED.install.library, ...many] },
};
for (const [width, height] of [
[1440, 900],
[390, 844],
]) {
await load(width, height, answer);
await open();
const scroll = await p.evaluate(() => {
const body = document.getElementById('localModelWindowBody')!;
const title = body.querySelector('.lm-title')!.getBoundingClientRect();
const box = body.getBoundingClientRect();
return {
overflows: body.scrollHeight > body.clientHeight,
top: body.scrollTop,
titleInside: title.top >= box.top - 0.5,
sideways: body.scrollWidth - body.clientWidth,
};
});
expect(scroll).toEqual({ overflows: true, top: 0, titleInside: true, sideways: 0 });
// The buttons of the screen stay in the frame's foot, under the part that scrolls.
const [foot] = await boxes('#localModelWindowFoot');
const [win] = await boxes('.local-model-window');
expect(foot.bottom).toBeLessThanOrEqual(win.bottom + 0.6);
await p.close();
}
});
it('the models on this Mac under 560px: two lines a row, nothing off the side', async () => {
await load(390, 844, INSTALLED);
await open();
const names = await boxes('.lm-lib-name');
const tags = await boxes('.lm-lib-tag');
const sizes = await boxes('.lm-lib-size');
for (let i = 0; i < names.length; i++) {
// The name and its size, then the state and what can be done.
expect(tags[i].top).toBeGreaterThanOrEqual(names[i].bottom - 1);
expect(sizes[i].top).toBeLessThan(names[i].bottom);
expect(same([names[i].left, tags[i].left])).toBe(true);
}
expect(same(sizes.map((box) => box.right))).toBe(true);
const buttons = await boxes('.lm-lib-row .lm-lib-actions button');
for (const button of buttons) expect(button.height).toBeGreaterThanOrEqual(36);
const overflow = await p.evaluate(() => {
const body = document.getElementById('localModelWindowBody')!;
return body.scrollWidth - body.clientWidth;
});
expect(overflow).toBeLessThanOrEqual(0);
});
it('paints above App Settings when it is opened from there', async () => {
await load(1440);
await p.evaluate(() => document.getElementById('appSettingsModal')!.classList.add('active'));
+2 -561
View File
@@ -18,10 +18,6 @@
* screen, and a 409 `size-changed` shows the new total and sends that one next;
* - the poll: once a second while the window is open, never after it closes, and a tick that
* moved only the download leaves the buttons in place;
* - the models on this Mac: the list with its sizes, the state of each row, "Use" (a switch
* with no confirm screen), "Delete" (asked once, in the row), "Add a model" (the chooser,
* where a row that is on this Mac already is switched to), the way back from a stopped job,
* and what an earlier Remove left on disk;
* - every payload string lands as text: an `<img onerror>` in a model, agent or chip name, an
* error's detail, a session name, a folder or a command creates no element;
* - a button exists only for an action the server lists.
@@ -52,7 +48,6 @@ import type {
LocalModelConflict,
LocalModelErrorCode,
LocalModelInstallView,
LocalModelLibraryRow,
LocalModelMessage,
LocalModelStatus,
NoneReason,
@@ -388,26 +383,11 @@ function install(over: Partial<LocalModelInstallView> = {}): LocalModelInstallVi
developerTools: 'present',
runtime: RUNTIME_MISSING,
folder: FOLDER,
library: [],
actions: ['install'],
...over,
};
}
/** The installed model as the row of the library it is: in use, checked, with nothing to do on it. */
const QWEN_ROW: LocalModelLibraryRow = {
id: 'qwen3.6:35b-a3b',
label: 'Qwen3.6 35B-A3B',
context: 131072,
sizeBytes: 23105798956,
sizeGb: '23.1',
active: true,
verified: true,
status: 'ready',
actions: [],
};
const STORE_ONE = { bytes: 23105798956, gb: '23.1' };
/** The members a job carries from `installing` on. */
const JOB: Partial<LocalModelInstallView> = {
model: { id: 'qwen3.6:35b-a3b', label: 'Qwen3.6 35B-A3B' },
@@ -473,9 +453,7 @@ const INSTALLED = status(
running: true,
endpointId: 'local-ollama',
loaded: { context: 131072, memoryBytes: 26300000000, memoryGb: '26.3', onGpu: true },
library: [QWEN_ROW],
store: STORE_ONE,
actions: ['start-session', 'stop', 'install', 'remove'],
actions: ['start-session', 'stop', 'remove'],
}
);
const STOPPED = status(
@@ -486,9 +464,7 @@ const STOPPED = status(
running: false,
endpointId: 'local-ollama',
loaded: { context: 131072, memoryBytes: 26300000000, memoryGb: '26.3', onGpu: true },
library: [QWEN_ROW],
store: STORE_ONE,
actions: ['start', 'install', 'remove'],
actions: ['start', 'remove'],
}
);
@@ -1729,8 +1705,6 @@ describe('an installed model', () => {
// row's own estimate, so the two figures are never read as a contradiction.
expect(texts(body, '.lm-aside')).toEqual([
'Runs with OpenCode. Context 131,072 tokens. Takes 26.3 GB of memory now, and up to about 36 GB at its peak, with all of the context in use. All of it is on the GPU.',
// Under the list of models: what the downloads take on disk.
'Downloaded models use 23.1 GB on this Mac.',
]);
expect(buttons(body)).toEqual(['Remove', 'Stop', 'Start a session']);
// One main action.
@@ -2395,536 +2369,3 @@ describe('an installed model whose runtime did not start', () => {
expect(verbs(app)).toEqual([['POST', '/api/local-model/runtime', { action: 'start' }]]);
});
});
// ---------- The models on this Mac ----------
const GPT_ROW: LocalModelLibraryRow = {
id: 'gpt-oss:20b',
label: 'GPT-OSS 20B',
context: 65536,
sizeBytes: 13793441427,
sizeGb: '13.8',
active: false,
verified: true,
status: 'ready',
actions: ['use', 'delete'],
};
const STORE_TWO = { bytes: 36899240383, gb: '36.9' };
/** Two models on this Mac, the Qwen one in use and running. */
const TWO = status(
{ budgetBytes: 40157944217, budgetGb: '40.2', budgetSource: 'measured' },
{
...INSTALLED.install,
library: [QWEN_ROW, GPT_ROW],
store: STORE_TWO,
actions: ['start-session', 'stop', 'install', 'use', 'delete-model', 'remove'],
}
);
/** The buttons of the rows of the list (not the buttons of the screen), by their label. */
const rowButtons = (page: Mounted) => texts(page.body, '.lm-lib-row .lm-lib-actions:not(.lm-lib-actions-away) button');
function pressIn(page: Mounted, selector: string, label: string) {
const found = [...page.frame.querySelectorAll<HTMLButtonElement>(`${selector} button`)].find(
(el) => el.textContent === label
);
expect(found, `no button "${label}" in ${selector}`).toBeTruthy();
found!.click();
}
describe('the models on this Mac', () => {
it('lists the one installed model with its context, its state and its size on disk, and offers another', async () => {
const page = mount();
await opened(page, INSTALLED);
const { body } = page;
expect(texts(body, '.lm-lib-caption-label')).toEqual(['Models on this Mac']);
expect(texts(body, '.lm-lib-caption-size')).toEqual(['On disk']);
expect(texts(body, '.lm-lib-label')).toEqual(['Qwen3.6 35B-A3B']);
expect(texts(body, '.lm-lib-meta')).toEqual(['131,072 tokens']);
expect(texts(body, '.lm-lib-tag .lm-tag')).toEqual(['In use']);
expect(texts(body, '.lm-lib-size')).toEqual(['23.1 GB']);
// The model in use wears the selected look, and nothing can be done ON it here.
expect(body.querySelector('.lm-lib-row')!.classList.contains('lm-lib-row-on')).toBe(true);
expect(rowButtons(page)).toEqual([]);
expect(texts(body, '.lm-lib-foot button')).toEqual(['Add a model']);
// A real list for assistive technology, named by its caption.
const list = body.querySelector('.lm-lib-list')!;
expect(list.getAttribute('role')).toBe('list');
expect(list.getAttribute('aria-labelledby')).toBe('localModelLibraryLabel');
expect(body.querySelectorAll('[role="listitem"]')).toHaveLength(1);
// The screen scrolls from its top: it is no short screen any more.
expect(body.dataset.long).toBe('true');
// The buttons of the screen are what they were.
expect(buttons(body)).toEqual(['Remove', 'Stop', 'Start a session']);
});
it('two models: the one in use first, the other with Use and Delete, and what both take on disk', async () => {
const page = mount();
await opened(page, TWO);
const { body } = page;
expect(texts(body, '.lm-lib-label')).toEqual(['Qwen3.6 35B-A3B', 'GPT-OSS 20B']);
expect(texts(body, '.lm-lib-meta')).toEqual(['131,072 tokens', '65,536 tokens']);
expect(texts(body, '.lm-lib-tag .lm-tag')).toEqual(['In use', 'Ready']);
expect(texts(body, '.lm-lib-size')).toEqual(['23.1 GB', '13.8 GB']);
expect(rowButtons(page)).toEqual(['Use', 'Delete']);
// A short label on screen, the model's name for a screen reader.
const [use, remove] = [...body.querySelectorAll('.lm-lib-row .lm-lib-actions button')];
expect(use.getAttribute('aria-label')).toBe('Use GPT-OSS 20B');
expect(remove.getAttribute('aria-label')).toBe('Delete GPT-OSS 20B');
// Quiet buttons: the screen keeps its one main action, and nothing here is red yet.
expect(body.querySelectorAll('.lm-library .wt-btn-primary, .lm-library .wt-btn-danger')).toHaveLength(0);
expect(texts(body, '.lm-lib-notes .lm-aside')).toEqual(['Downloaded models use 36.9 GB on this Mac.']);
});
it('a row has a button only for an action the server lists for THAT row', async () => {
const page = mount();
const rows: LocalModelLibraryRow[] = [
QWEN_ROW,
{ ...GPT_ROW, actions: ['use'] },
{ ...GPT_ROW, id: 'a:1b', label: 'A', status: 'changed', actions: ['delete'] },
{ ...GPT_ROW, id: 'b:1b', label: 'B', status: 'missing', actions: ['delete'] },
{ ...GPT_ROW, id: 'c:1b', label: 'C', context: undefined, verified: false, status: 'unverified', actions: [] },
{ id: 'd:1b', label: 'D', active: false, verified: false, status: 'unverified', actions: ['delete'] },
];
await opened(page, status({}, { ...TWO.install, library: rows }));
const perRow = [...page.body.querySelectorAll('.lm-lib-row')].map((row) => [
row.querySelector('.lm-lib-label')!.textContent,
row.querySelector('.lm-tag')!.textContent,
texts(row, '.lm-lib-actions button').join('+'),
]);
expect(perRow).toEqual([
['Qwen3.6 35B-A3B', 'In use', ''],
['GPT-OSS 20B', 'Ready', 'Use'],
['A', 'Changed', 'Delete'],
['B', 'Missing', 'Delete'],
['C', 'Not checked', ''],
['D', 'Not checked', 'Delete'],
]);
// A row without a context or a size prints neither: no "undefined", no empty unit.
const last = [...page.body.querySelectorAll('.lm-lib-row')].at(-1)!;
expect(last.querySelector('.lm-lib-meta')).toBeNull();
expect(last.querySelector('.lm-lib-size')!.textContent).toBe('');
expect(page.body.textContent).not.toContain('undefined');
// Each state has a colour of its own.
expect([...page.body.querySelectorAll('.lm-lib-tag .lm-tag')].map((tag) => tag.className)).toEqual([
'lm-tag lm-tag-inuse',
'lm-tag lm-tag-ready',
'lm-tag lm-tag-changed',
'lm-tag lm-tag-missing',
'lm-tag lm-tag-unverified',
'lm-tag lm-tag-unverified',
]);
});
it('Use switches at once: no confirm screen, and the screen says a switch is running', async () => {
const page = mount();
const switching: LocalModelInstallView = {
...TWO.install,
state: 'installing',
step: 'verify',
steps: ['verify', 'connect'],
model: { id: 'gpt-oss:20b', label: 'GPT-OSS 20B' },
context: 65536,
library: [
{ ...QWEN_ROW, active: false },
{ ...GPT_ROW, actions: [] },
],
actions: ['cancel'],
};
const { app } = await opened(page, TWO, () => ok(switching));
pressIn(page, '.lm-lib-row', 'Use');
await settle();
expect(verbs(app)).toEqual([['POST', '/api/local-model/use', { modelId: 'gpt-oss:20b' }]]);
expect(page.body.dataset.screen).toBe('progress');
expect(texts(page.body, '.lm-title')).toEqual(['Switching to GPT-OSS 20B']);
// Nothing is downloaded: no download bar, and the two steps of a switch.
expect(page.body.querySelector('.lm-meter')).toBeNull();
expect(texts(page.body, '.lm-step-label')).toEqual(['Loading and checking', 'Connecting']);
expect(texts(page.body, '.lm-aside')).toEqual([
'You can close this window. The switch continues while Codeman is running.',
]);
expect(buttons(page.body)).toEqual(['Cancel']);
});
it('a switch that is refused keeps the screen and prints the server sentence with the session names', async () => {
const page = mount();
const { app } = await opened(page, TWO, () =>
conflict({
reason: 'sessions-open',
message: { text: LOCAL_MODEL_INSTALL_LABELS.conflictSessionsOpen, vars: { sessions: 'w1-app, w2-api' } },
sessions: ['w1-app', 'w2-api'],
})
);
pressIn(page, '.lm-lib-row', 'Use');
await settle();
expect(page.body.dataset.screen).toBe('installed');
expect(texts(page.body, '.lm-notice-text')).toEqual([
'Sessions are still using this model: w1-app, w2-api. Close them first.',
]);
expect(verbs(app)).toHaveLength(1);
expect(rowButtons(page)).toEqual(['Use', 'Delete']);
});
it('Delete asks once, in the row, and sends nothing until the red button is pressed', async () => {
const page = mount();
const after: LocalModelInstallView = {
...TWO.install,
library: [QWEN_ROW],
store: STORE_ONE,
actions: ['start-session', 'stop', 'install', 'remove'],
};
const { app } = await opened(page, TWO, () => ok(after));
pressIn(page, '.lm-lib-row', 'Delete');
await settle();
expect(verbs(app)).toEqual([]);
const ask = page.body.querySelector('.lm-lib-ask')!;
expect(texts(ask, '.lm-lead')).toEqual(['Delete GPT-OSS 20B from this Mac?']);
expect(texts(ask, '.lm-aside')).toEqual(['This frees 13.8 GB.']);
expect(texts(ask, 'button')).toEqual(['Cancel', 'Delete']);
// Red only here, in its own question.
expect(page.frame.querySelectorAll('.wt-btn-danger')).toHaveLength(1);
expect(ask.querySelector('.wt-btn-danger')!.textContent).toBe('Delete');
// The question sits right under the row it is about. That row's own buttons keep their
// room (the sizes must not jump) and are out of reach: unseen, disabled, hidden from a reader.
const asked = ask.previousElementSibling!;
expect(asked.querySelector('.lm-lib-label')!.textContent).toBe('GPT-OSS 20B');
expect(rowButtons(page)).toEqual([]);
const away = asked.querySelector('.lm-lib-actions')!;
expect(away.classList.contains('lm-lib-actions-away')).toBe(true);
expect(away.getAttribute('aria-hidden')).toBe('true');
expect([...away.querySelectorAll('button')].every((button) => button.disabled)).toBe(true);
// The focus is on the safe answer.
expect((page.document.activeElement as HTMLElement).textContent).toBe('Cancel');
pressIn(page, '.lm-lib-ask', 'Cancel');
await settle();
expect(page.body.querySelector('.lm-lib-ask')).toBeNull();
expect(rowButtons(page)).toEqual(['Use', 'Delete']);
expect(verbs(app)).toEqual([]);
// Back on the button that asked.
expect((page.document.activeElement as HTMLElement).dataset.lmKey).toBe('delete:gpt-oss:20b');
pressIn(page, '.lm-lib-row', 'Delete');
await settle();
pressIn(page, '.lm-lib-ask', 'Delete');
await settle();
expect(verbs(app)).toEqual([['DELETE', '/api/local-model/models', { modelId: 'gpt-oss:20b' }]]);
expect(texts(page.body, '.lm-lib-label')).toEqual(['Qwen3.6 35B-A3B']);
expect(page.body.querySelector('.lm-lib-ask')).toBeNull();
expect(texts(page.body, '.lm-lib-notes .lm-aside')).toEqual(['Downloaded models use 23.1 GB on this Mac.']);
});
it('a delete the server refuses leaves the row and prints why', async () => {
const page = mount();
await opened(page, TWO, () =>
conflict({
reason: 'model-active',
message: { text: LOCAL_MODEL_INSTALL_LABELS.conflictModelActive, vars: { model: 'GPT-OSS 20B' } },
})
);
pressIn(page, '.lm-lib-row', 'Delete');
await settle();
pressIn(page, '.lm-lib-ask', 'Delete');
await settle();
expect(texts(page.body, '.lm-notice-text')).toEqual([
'GPT-OSS 20B is the model in use. Switch to another model first, or remove the local model.',
]);
expect(texts(page.body, '.lm-lib-label')).toEqual(['Qwen3.6 35B-A3B', 'GPT-OSS 20B']);
});
it('the Delete question of a row and the Remove question are never open together', async () => {
const page = mount();
await opened(page, TWO);
pressIn(page, '.lm-lib-row', 'Delete');
await settle();
expect(page.body.querySelector('.lm-lib-ask')).not.toBeNull();
press(page.body, 'Remove');
await settle();
expect(page.frame.querySelector('.lm-remove')).not.toBeNull();
expect(page.frame.querySelector('.lm-lib-ask')).toBeNull();
});
it('with the runtime stopped a row offers no delete, and one line says how to get it', async () => {
const page = mount();
const stopped = status(
{},
{
...STOPPED.install,
library: [QWEN_ROW, { ...GPT_ROW, actions: ['use'] }],
store: STORE_TWO,
actions: ['start', 'install', 'use', 'remove'],
}
);
await opened(page, stopped);
expect(rowButtons(page)).toEqual(['Use']);
expect(texts(page.body, '.lm-lib-notes .lm-aside')).toEqual([
'Downloaded models use 36.9 GB on this Mac.',
'Press Start to delete a single model.',
]);
// With one model there is no single model to delete, so the line is not there.
const one = mount();
await opened(one, STOPPED);
expect(one.body.textContent).not.toContain('Press Start to delete');
});
it('the screen that ends an install says one thing: no list, one button', async () => {
const page = mount();
const { app, serve } = await opened(page, INSTALLING);
serve(TWO);
await app.loadLocalModel({ force: true });
expect(page.body.dataset.screen).toBe('installed');
expect(page.body.querySelector('.lm-library')).toBeNull();
expect(page.body.dataset.long).toBe('false');
expect(buttons(page.body)).toEqual(['Start a session']);
});
it('"Add a model" opens the chooser on an installed model, and Back returns to it', async () => {
const page = mount();
const { app } = await opened(page, TWO);
pressIn(page, '.lm-lib-foot', 'Add a model');
await settle();
expect(page.body.dataset.screen).toBe('choose');
expect(page.body.querySelectorAll('[role="radio"]').length).toBeGreaterThan(1);
expect(buttons(page.body)).toEqual(['Back', 'Install on this Mac']);
press(page.body, 'Back');
await settle();
expect(page.body.dataset.screen).toBe('installed');
expect(verbs(app)).toEqual([]);
// No button without the server offering an install (no row fits, or it is not listed).
const none = mount();
await opened(none, status({}, { ...TWO.install, actions: ['start-session', 'stop', 'remove'] }));
expect(texts(none.body, '.lm-lib-foot button')).toEqual([]);
});
it('in the chooser a model that is on this Mac says so, and is switched to without a download', async () => {
const page = mount();
const answer = status(
{
pick: { ...PICK, library: 'active' },
alternatives: [{ ...ALT_SLOW, library: 'ready' }, ALT_SMALL],
},
TWO.install
);
const { app } = await opened(page, answer, () => ok({ ...TWO.install, state: 'installing', step: 'verify' }));
pressIn(page, '.lm-lib-foot', 'Add a model');
await settle();
const rows = [...page.body.querySelectorAll('.lm-model')];
const tagsOf = (row: Element) => texts(row, '.lm-tag');
// The model in use: its own tag beside the server's, and nothing to do with it here.
expect(tagsOf(rows[0])).toContain('In use');
const primary = () => page.frame.querySelector<HTMLButtonElement>('.lm-buttons .wt-btn-primary')!;
expect(primary().textContent).toBe('In use');
expect(primary().disabled).toBe(true);
// A model that is on this Mac and checked: one click switches to it.
const ready = rows.find((row) => tagsOf(row).includes('On this Mac'))!;
expect(ready).toBeTruthy();
(ready as HTMLElement).click();
await settle();
expect(primary().textContent).toBe('Use this model');
expect(primary().disabled).toBe(false);
primary().click();
await settle();
expect(verbs(app)).toEqual([['POST', '/api/local-model/use', { modelId: ALT_SLOW.modelId }]]);
// No confirm screen was shown on the way.
expect(page.body.dataset.screen).toBe('progress');
});
it('in the chooser a model that is not on this Mac is installed the usual way, with its sizes confirmed', async () => {
const page = mount();
const answer = status({ pick: { ...PICK, library: 'active' }, alternatives: [ALT_SMALL] }, TWO.install);
const { app } = await opened(page, answer, () => ok({ ...TWO.install, state: 'installing', step: 'model' }));
pressIn(page, '.lm-lib-foot', 'Add a model');
await settle();
const other = [...page.body.querySelectorAll<HTMLElement>('.lm-model')].find(
(row) => row.dataset.modelId === ALT_SMALL.modelId
)!;
other.click();
await settle();
press(page.body, 'Install on this Mac');
await settle();
expect(page.body.dataset.screen).toBe('confirm');
press(page.body, 'Download and install');
await settle();
expect(verbs(app)).toEqual([
[
'POST',
'/api/local-model/install',
{
modelId: ALT_SMALL.modelId,
cli: ALT_SMALL.harnesses[0].cli,
context: ALT_SMALL.context,
acknowledgedBytes: ALT_SMALL.installBytes,
},
],
]);
});
it('a job that stopped beside a checked model shows the way back to that model', async () => {
const back: LocalModelLibraryRow = { ...QWEN_ROW, active: false, actions: ['use', 'delete'] };
for (const first of [
status(
{},
{ ...PAUSED.install, library: [back], actions: ['resume', 'install', 'use', 'delete-model', 'remove'] }
),
status(
{},
{
...failed('no-tool-call', { model: 'GPT-OSS 20B' }).install,
library: [back],
actions: ['resume', 'install', 'use', 'delete-model', 'remove'],
}
),
]) {
const page = mount();
const { app } = await opened(page, first, () => ok({ ...TWO.install, state: 'installing', step: 'verify' }));
expect(texts(page.body, '.lm-lib-label')).toEqual(['Qwen3.6 35B-A3B']);
expect(texts(page.body, '.lm-lib-tag .lm-tag')).toEqual(['Ready']);
expect(rowButtons(page)).toEqual(['Use', 'Delete']);
// No "Add a model" here: the job's own buttons offer another model.
expect(texts(page.body, '.lm-lib-foot button')).toEqual([]);
pressIn(page, '.lm-lib-row', 'Use');
await settle();
expect(verbs(app)).toEqual([['POST', '/api/local-model/use', { modelId: 'qwen3.6:35b-a3b' }]]);
}
});
it('a first job that stopped has nothing to go back to, and shows no list', async () => {
const page = mount();
// Its own model is on disk and checked, with nothing offered on it: Resume goes on with it.
await opened(page, status({}, { ...PAUSED.install, library: [{ ...QWEN_ROW, active: false }] }));
expect(page.body.querySelector('.lm-library')).toBeNull();
expect(page.body.dataset.long).toBe('false');
const stopped = mount();
await opened(stopped, failed('load-failed', { model: 'Qwen3.6 35B-A3B' }));
expect(stopped.body.querySelector('.lm-library')).toBeNull();
});
it('a paused switch says so, and waits', async () => {
const page = mount();
await opened(
page,
status({}, { ...PAUSED.install, step: 'verify', steps: ['verify', 'connect'], progress: undefined })
);
expect(texts(page.body, '.lm-title')).toEqual(['The switch to Qwen3.6 35B-A3B is paused']);
expect(texts(page.body, '.lm-aside')).toEqual(['The switch waits until you continue.']);
expect(page.body.querySelector('.lm-meter')).toBeNull();
});
it('the App Settings row names a switch as a switch', async () => {
const page = mount();
const job = { ...JOB, steps: ['verify', 'connect'] as LocalModelInstallView['steps'], step: 'verify' as const };
for (const [state, text] of [
['installing', 'Switching to Qwen3.6 35B-A3B'],
['paused', 'The switch to Qwen3.6 35B-A3B is paused'],
['failed', 'The switch to Qwen3.6 35B-A3B stopped'],
] as const) {
const app = page.newApp(ok(status({}, { ...job, state, actions: [] })));
await app.loadLocalModel({ force: true });
expect(texts(page.row, '.set-row-label')).toEqual([text]);
}
});
});
describe('what an earlier Remove left on disk', () => {
const LEFT = status({}, { store: STORE_ONE, actions: ['install', 'remove'] });
it('the chooser says what the downloads still take, and deletes them after one question', async () => {
const page = mount();
const { app } = await opened(page, LEFT, () => ok(install({ store: { bytes: 0, gb: '0.0' } })));
expect(texts(page.body, '.lm-leftovers .lm-aside')).toEqual([
'Models from an earlier install still use 23.1 GB on this Mac.',
]);
pressIn(page, '.lm-leftovers', 'Delete them');
await settle();
expect(verbs(app)).toEqual([]);
expect(texts(page.body, '.lm-leftovers .lm-lead')).toEqual(['Delete the downloaded models from this Mac?']);
expect(texts(page.body, '.lm-leftovers .lm-lib-ask .lm-aside')).toEqual(['This frees 23.1 GB.']);
expect(page.body.querySelector('.lm-leftovers .wt-btn-danger')!.textContent).toBe('Delete');
pressIn(page, '.lm-leftovers', 'Cancel');
await settle();
expect(texts(page.body, '.lm-leftovers button')).toEqual(['Delete them']);
pressIn(page, '.lm-leftovers', 'Delete them');
await settle();
pressIn(page, '.lm-leftovers', 'Delete');
await settle();
expect(verbs(app)).toEqual([['DELETE', '/api/local-model', { removeModel: true }]]);
expect(page.body.querySelector('.lm-leftovers')).toBeNull();
// Still the chooser: installing is what this screen is for.
expect(page.body.dataset.screen).toBe('choose');
expect(buttons(page.body)).toEqual(['Install on this Mac']);
});
it('is not there without something on disk, or without the server offering the removal', async () => {
for (const first of [
IDLE,
status({}, { store: { bytes: 0, gb: '0.0' }, actions: ['install', 'remove'] }),
status({}, { store: STORE_ONE, actions: ['install'] }),
]) {
const page = mount();
await opened(page, first);
expect(page.body.querySelector('.lm-leftovers')).toBeNull();
}
});
it('shows on a Mac no model fits too: the space can still be freed', async () => {
const page = mount();
const answer = none('memory');
await opened(page, { ...answer, install: install({ store: STORE_ONE, actions: ['remove'] }) });
expect(page.body.dataset.screen).toBe('none');
expect(texts(page.body, '.lm-leftovers .lm-aside')).toEqual([
'Models from an earlier install still use 23.1 GB on this Mac.',
]);
});
});
describe('the models on this Mac: text of someone else, another language, an unchanged answer', () => {
it('a model name is text, in the row, in its question and in the labels of its buttons', async () => {
const HOSTILE = '<img src=x onerror="window.__pwned=1">';
const page = mount();
const rows: LocalModelLibraryRow[] = [QWEN_ROW, { ...GPT_ROW, id: `evil:1b`, label: `Model ${HOSTILE}` }];
await opened(page, status({}, { ...TWO.install, library: rows, folder: `/tmp/${HOSTILE}` }));
pressIn(page, '.lm-lib-row', 'Delete');
await settle();
expect(page.document.querySelectorAll('img, script, iframe')).toHaveLength(0);
expect((page.win as unknown as { __pwned?: number }).__pwned).toBeUndefined();
expect(texts(page.body, '.lm-lib-ask .lm-lead')).toEqual([`Delete Model ${HOSTILE} from this Mac?`]);
});
it('is translated when the language changes, the names staying as sent', async () => {
const page = mount({ i18n: true });
page.win.CodemanI18n.start();
await opened(page, TWO);
page.win.CodemanI18n.configure({ language: 'zh-CN' });
await settle();
expect(texts(page.body, '.lm-lib-caption-label')).toEqual(['此 Mac 上的模型']);
expect(texts(page.body, '.lm-lib-tag .lm-tag')).toEqual(['使用中', '就绪']);
expect(rowButtons(page)).toEqual(['使用', '删除']);
expect(texts(page.body, '.lm-lib-label')).toEqual(['Qwen3.6 35B-A3B', 'GPT-OSS 20B']);
expect(texts(page.body, '.lm-lib-notes .lm-aside')).toEqual(['已下载的模型在此 Mac 上占用 36.9 GB。']);
expect(texts(page.body, '.lm-lib-foot button')).toEqual(['添加模型']);
});
it('a poll that changes nothing leaves the rows and their buttons in place', async () => {
const page = mount();
await opened(page, TWO);
const before = page.body.querySelector('.lm-lib-row .lm-lib-actions button');
await page.tick();
await page.tick();
expect(page.body.querySelector('.lm-lib-row .lm-lib-actions button')).toBe(before);
});
it('the focus stays on the row button it was on when the list is repainted', async () => {
const page = mount();
const { app, serve } = await opened(page, TWO);
const remove = [...page.body.querySelectorAll<HTMLButtonElement>('.lm-lib-row .lm-lib-actions button')][1];
remove.focus();
serve(status({}, { ...TWO.install, store: { bytes: 36899240384, gb: '36.9' }, port: 11436 }));
await app.loadLocalModel({ force: true });
const active = page.document.activeElement as HTMLElement;
expect(active.textContent).toBe('Delete');
expect(active.dataset.lmKey).toBe('delete:gpt-oss:20b');
expect(active).not.toBe(remove);
});
});
+2
View File
@@ -35,6 +35,7 @@ it('pins every remote pane command', () => {
deepseek: remoteLoginShellCommand('dsh'),
omp: remoteLoginShellCommand('omp'),
copilot: remoteLoginShellCommand('copilot'),
jcode: remoteLoginShellCommand('jcode'),
};
for (const [mode, want] of Object.entries(expected)) {
expect(defaultRemoteCommandForMode(mode as SessionMode), mode).toBe(want);
@@ -55,6 +56,7 @@ it('pins every in-container pane command', () => {
deepseek: 'exec dsh',
omp: 'exec omp',
copilot: 'exec copilot',
jcode: 'exec jcode',
};
for (const [mode, want] of Object.entries(expected)) {
expect(defaultDockerCommandForMode(mode as SessionMode), mode).toBe(want);
+1
View File
@@ -470,6 +470,7 @@ describe('mobile overview run picker (CLI availability gating)', () => {
'deepseek',
'omp',
'copilot',
'jcode',
'shell',
]);
});
+45 -1
View File
@@ -15,7 +15,13 @@
*/
import { describe, expect, it, vi } from 'vitest';
import { collectDescendants, PROC_WALK_MAX_DEPTH, PROC_WALK_MAX_NODES } from '../src/proc-tree.js';
import {
collectDescendants,
descendantsInPaneGroup,
PROC_WALK_MAX_DEPTH,
PROC_WALK_MAX_NODES,
} from '../src/proc-tree.js';
import { STOCK_CLIS } from '../src/config/cli-registry/stock.js';
/** Build a parent→children map from `[parent, child]` pairs. */
function tree(pairs: [number, number][]): Map<number, number[]> {
@@ -198,3 +204,41 @@ describe('TmuxManager uses the bounded walk', () => {
expect(out.length).toBeLessThanOrEqual(PROC_WALK_MAX_NODES);
});
});
describe('descendantsInPaneGroup (a shared daemon outlives the pane that started it)', () => {
// The jcode shape, measured: pane 100 runs the client (group 100); the client spawned the
// daemon 200, which setsid()'d into group 200 and runs tools 300 and 301 for EVERY jcode
// session; 110 is an ordinary child of the client.
const descendants = collectDescendants(
100,
tree([
[100, 110],
[100, 200],
[200, 300],
[200, 301],
])
);
const groups = new Map([
[100, 100],
[110, 100],
[200, 200],
[300, 200],
[301, 200],
]);
it("keeps the pane's own group and spares the daemon with its whole subtree", () => {
expect(descendants.sort()).toEqual([110, 200, 300, 301]);
expect(descendantsInPaneGroup(descendants, groups, 100)).toEqual([110]);
});
it('kills a pid whose group could not be read (it most likely exited already)', () => {
const partial = new Map(groups);
partial.delete(110);
expect(descendantsInPaneGroup(descendants, partial, 100)).toEqual([110]);
});
it('is declared by jcode alone, so every other CLI keeps the full descendant kill', () => {
const declaring = STOCK_CLIS.filter((e) => e.capabilities.sharedDaemon === true).map((e) => e.id as string);
expect(declaring).toEqual(['jcode']);
});
});
+3
View File
@@ -242,6 +242,9 @@ describe('WebServer.renderIndexHtml', () => {
deepseekBinary: false,
omp: true,
copilot: true,
// No per-CLI resolver module: the registry's generic one answers, and it resolves
// nothing under vitest (its probes would otherwise run whatever `jcode` is on PATH).
jcode: false,
cloudflared: true,
git: true,
shell: true,
+1 -193
View File
@@ -155,7 +155,6 @@ const IDLE_VIEW: LocalModelInstallView = {
developerTools: 'missing',
runtime: { version: '0.40.15', installed: false, downloadBytes: 185971671, downloadGb: '0.2', unpackedGb: '0.6' },
folder: '/Users/me/.codeman/local-model',
library: [],
actions: ['install'],
};
@@ -183,8 +182,6 @@ function fakeService(): ServiceMock {
launchRefusalMessage: vi.fn(async () => undefined),
recommendOptions: vi.fn(async () => ({ runtimeDownloadBytes: 185971671 })),
install: vi.fn(async () => OK_RESULT),
use: vi.fn(async () => OK_RESULT),
deleteLibraryModel: vi.fn(async () => OK_RESULT),
cancel: vi.fn(async () => OK_RESULT),
resume: vi.fn(async () => OK_RESULT),
runtime: vi.fn(async () => OK_RESULT),
@@ -558,13 +555,6 @@ describe('the install verbs', () => {
/** Every verb as a request, with a body the route accepts. */
const VERBS = [
{ name: 'install', method: 'POST', url: '/api/local-model/install', payload: INSTALL_BODY },
{ name: 'use', method: 'POST', url: '/api/local-model/use', payload: { modelId: 'qwen3.8:27b' } },
{
name: 'deleteLibraryModel',
method: 'DELETE',
url: '/api/local-model/models',
payload: { modelId: 'qwen3.8:27b' },
},
{ name: 'cancel', method: 'POST', url: '/api/local-model/cancel', payload: undefined },
{ name: 'resume', method: 'POST', url: '/api/local-model/resume', payload: {} },
{ name: 'runtime', method: 'POST', url: '/api/local-model/runtime', payload: { action: 'stop' } },
@@ -664,18 +654,7 @@ describe('the install verbs', () => {
const f = fakes();
const { app } = await harness(f.deps, { username: 'alice', role: 'admin' });
await app.inject({ method: 'POST', url: '/api/local-model/install', payload: INSTALL_BODY });
expect(f.service.install).toHaveBeenCalledWith(INSTALL_BODY, { username: 'alice', ip: '127.0.0.1' }, []);
});
it('hands the service the names of the live sessions on the model: another model would take the runtime from them', async () => {
const f = fakes();
const { app } = await harness(f.deps, undefined, [
{ name: 'w1-app', customModel: { endpointId: 'local-ollama' } },
{ name: 'w2-app' },
{ name: 'w3-lib', customModel: { endpointId: 'office-gpu' } },
]);
await app.inject({ method: 'POST', url: '/api/local-model/install', payload: INSTALL_BODY });
expect(f.service.install.mock.calls[0][2]).toEqual(['w1-app']);
expect(f.service.install).toHaveBeenCalledWith(INSTALL_BODY, { username: 'alice', ip: '127.0.0.1' });
});
it('a row the server does not offer is a 400 with the service own sentence, as text and as data', async () => {
@@ -946,177 +925,6 @@ describe('the install verbs', () => {
});
});
describe('the library verbs', () => {
const SESSIONS: SessionLike[] = [
{ name: 'w1-app', customModel: { endpointId: 'local-ollama' } },
{ name: 'w2-app' },
{ name: 'w3-lib', customModel: { endpointId: 'office-gpu' } },
];
describe('POST /api/local-model/use', () => {
it('hands the service the model, who asked and the live sessions on the model', async () => {
const f = fakes();
const { app } = await harness(f.deps, { username: 'alice', role: 'admin' }, SESSIONS);
await app.inject({ method: 'POST', url: '/api/local-model/use', payload: { modelId: 'gpt-oss:20b' } });
expect(f.service.use).toHaveBeenCalledWith({ modelId: 'gpt-oss:20b' }, { username: 'alice', ip: '127.0.0.1' }, [
'w1-app',
]);
});
it('a model that is not one of the checked ones is a 400 with the sentence, as text and as data', async () => {
const f = fakes();
f.service.use.mockResolvedValue({
ok: false,
status: 400,
message: { text: LOCAL_MODEL_INSTALL_LABELS.refusedNotInLibrary },
});
const { app } = await harness(f.deps);
const res = await app.inject({ method: 'POST', url: '/api/local-model/use', payload: { modelId: 'x:1b' } });
expect(res.statusCode).toBe(400);
expect(res.json()).toEqual({
success: false,
error: 'This model is not one of the checked models on this Mac.',
errorCode: 'INVALID_INPUT',
data: { message: { text: 'This model is not one of the checked models on this Mac.' } },
});
});
it.each([
['no body', undefined],
['no model', {}],
['a path', { modelId: '../../etc/passwd' }],
['another registry', { modelId: 'hf.co/someone/model:Q4' }],
['a member it does not know', { modelId: 'gpt-oss:20b', context: 65536 }],
])('refuses %s before the service is asked', async (_name, payload) => {
const f = fakes();
const { app } = await harness(f.deps);
const res = await app.inject({ method: 'POST', url: '/api/local-model/use', ...(payload ? { payload } : {}) });
expect(res.statusCode).toBe(400);
expect(f.service.use).not.toHaveBeenCalled();
});
});
describe('DELETE /api/local-model/models', () => {
it('hands the service the model name and who asked', async () => {
const f = fakes();
const { app } = await harness(f.deps, { username: 'alice', role: 'admin' });
await app.inject({ method: 'DELETE', url: '/api/local-model/models', payload: { modelId: 'gpt-oss:20b' } });
expect(f.service.deleteLibraryModel).toHaveBeenCalledWith(
{ modelId: 'gpt-oss:20b' },
{ username: 'alice', ip: '127.0.0.1' }
);
// The whole installation is another route: it was not asked.
expect(f.service.remove).not.toHaveBeenCalled();
});
it.each([
['no body', undefined],
['a path', { modelId: '/Users/me/.codeman/local-model/models' }],
['a path that climbs', { modelId: '../../etc/passwd' }],
['a name with a slash', { modelId: 'library/qwen3.8:27b' }],
['a name without a tag', { modelId: 'qwen3.8' }],
['a path member', { modelId: 'gpt-oss:20b', path: '/' }],
])('takes a model name and nothing else: refuses %s', async (_name, payload) => {
const f = fakes();
const { app } = await harness(f.deps);
const res = await app.inject({
method: 'DELETE',
url: '/api/local-model/models',
...(payload ? { payload } : {}),
});
expect(res.statusCode).toBe(400);
expect(f.service.deleteLibraryModel).not.toHaveBeenCalled();
});
it('the model in use: 409 with the reason and the sentence', async () => {
const f = fakes();
f.service.deleteLibraryModel.mockResolvedValue({
ok: false,
status: 409,
conflict: {
reason: 'model-active',
message: { text: LOCAL_MODEL_INSTALL_LABELS.conflictModelActive, vars: { model: 'Qwen3.8 27B' } },
},
});
const { app } = await harness(f.deps);
const res = await app.inject({
method: 'DELETE',
url: '/api/local-model/models',
payload: { modelId: 'qwen3.8:27b' },
});
expect(res.statusCode).toBe(409);
expect(res.json()).toEqual({
success: false,
error: 'Qwen3.8 27B is the model in use. Switch to another model first, or remove the local model.',
errorCode: 'CONFLICT',
data: {
reason: 'model-active',
message: {
text: '{model} is the model in use. Switch to another model first, or remove the local model.',
vars: { model: 'Qwen3.8 27B' },
},
},
});
});
it('a runtime that did not delete it: 500 with the sentence, as text and as data', async () => {
const f = fakes();
f.service.deleteLibraryModel.mockResolvedValue({
ok: false,
status: 500,
message: { text: LOCAL_MODEL_INSTALL_LABELS.deleteFailed, vars: { model: 'GPT-OSS 20B' } },
});
const { app } = await harness(f.deps);
const res = await app.inject({
method: 'DELETE',
url: '/api/local-model/models',
payload: { modelId: 'gpt-oss:20b' },
});
expect(res.statusCode).toBe(500);
expect(res.json()).toEqual({
success: false,
error: 'GPT-OSS 20B could not be deleted from this Mac.',
errorCode: 'OPERATION_FAILED',
data: { message: { text: '{model} could not be deleted from this Mac.', vars: { model: 'GPT-OSS 20B' } } },
});
});
});
describe('GET /api/local-model marks the rows that are already on this Mac', () => {
it('the model in use and a model a switch can take, at the context they were checked with', async () => {
const second = { ...OK.pick!, modelId: 'gpt-oss:20b', modelLabel: 'GPT-OSS 20B', context: 65536 };
const third = { ...OK.pick!, modelId: 'mimo-v2.6:9b-q4_k_m', modelLabel: 'MiMo V2.6 9B' };
const f = fakes(MAC, { ...OK, alternatives: [second, third] });
const row = (id: string, context: number, active: boolean) => ({
id,
label: id,
context,
active,
verified: true,
status: 'ready' as const,
actions: active ? [] : ['use' as const],
});
f.service.view.mockResolvedValue({
...IDLE_VIEW,
state: 'installed',
library: [row(OK.pick!.modelId, OK.pick!.context, true), row('gpt-oss:20b', 65536, false)],
actions: ['start-session', 'stop', 'install', 'use', 'remove'],
});
const data = (await get(f.deps)).json().data;
expect(data.recommendation.pick.library).toBe('active');
expect(data.recommendation.alternatives[0].library).toBe('ready');
expect(data.recommendation.alternatives[1]).not.toHaveProperty('library');
// What recommend() answered is not written to.
expect(OK.pick).not.toHaveProperty('library');
});
it('with nothing on this Mac no row carries the mark', async () => {
const data = (await get(fakes().deps)).json().data;
expect(data.recommendation.pick).not.toHaveProperty('library');
});
});
});
describe('the routes over the real install service', () => {
const STATE = () => dataPath('local-model.json');
@@ -215,6 +215,24 @@ describe('GET /api/sessions/:id/last-response — external CLI panes', () => {
});
});
it('answers jcode with an honest empty, never a segmented pane', async () => {
// jcode is a repainting alternate-screen TUI: segmenting its stream served the
// welcome banner as "the answer" (measured on a live pane), and the Claude scan
// below the external branch could adopt a Claude conversation from the same folder.
// It has no transcript reader yet, so "nothing said yet" is the only honest reply.
session.mode = 'jcode';
session.terminalBuffer = PANE;
const { res, body } = await lastResponse();
expect(res.statusCode).toBe(200);
expect(body.data.text).toBe('');
expect(body.data.messages).toBeUndefined();
const full = await lastResponse(true);
expect(full.body.data.text).toBe('');
expect(full.body.data.messages).toEqual([]);
});
it('leaves Claude mode on the Claude transcript path', async () => {
// Regression guard: a claude pane must NOT be segmented off its terminal
// buffer, or a real transcript would be shadowed by scraped pane text.