diff --git a/CLAUDE.md b/CLAUDE.md index 245016ee7..97171e5f9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -194,7 +194,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph **Local model recommendation** (`GET /api/local-model`, `src/local-model/`, `src/config/local-model-catalog.ts`, `src/types/local-model.ts`; EXPERIMENTAL): the server looks at the Mac it runs on and says which local coding model fits it and with which agent CLI. ⚠️ The READ side stays read-only: the probe, the catalogue and `recommend()` download nothing, install nothing and write nothing; everything that changes the machine is the install below, behind an explicit request. Apple silicon macOS only: anywhere else the answer is a `200` with verdict `none` (reasons `not-macos`, `intel`, `memory`, `disk`, `macos`, `no-harness`), and a failed probe is an answer, never a 500. The probe (`hardware.ts`) is ONE bounded async spawn of `/usr/sbin/sysctl` WITHOUT `-n`, parsed as `key: value` lines by the pure `parseSysctl()`, plus `statfs` of the data dir (probed bounded first, #516) and `node:os` fallbacks, cached 60 s; ⚠️ never `system_profiler`, never a second spawn, and `iogpu.wired_limit_mb` of 0 means not set. The catalogue is a compiled constant validated by a Zod schema: ⚠️ no override file and no runtime data, and a model id is `name:tag` in lowercase with no slash (`LOCAL_MODEL_ID_PATTERN`, so an id can never name another registry host). ⚠️ A harness is a catalogue ROW (`{ cli, minContext, rank, gated?, slowFirstTurn? }`) plus a `HarnessFact` built from the CLI registry at call time (`harness-facts.ts`); no code compares a CLI id (both id-branching guards scan these files). `recommend()` (`recommend.ts`) is pure. ⚠️ A model's need is its PEAK (`bytes + heldBytesPerToken * context + overheadBytes + peakExtraBytes`, measured on a Mac for one row and marked `estimated` on the others), and every row names its `runner` (`mlx` or `llamacpp`, the build a Mac really pulls) and carries figures of its OWN: computed from its model's config.json by that runner's rule (catalogue header, MEMORY), never borrowed from another row; `test/local-model-catalog.test.ts` recomputes them, and the `llamacpp` rows assume the runtime's `OLLAMA_KV_CACHE_TYPE=q8_0`, `LLAMA_ARG_CACHE_RAM=1024` and `LLAMA_ARG_CTX_CHECKPOINTS=8` (`runtimeEnv()`) and count what llama-server keeps beside the cache (8 context checkpoints of the recurrent or sliding-window state, and for a GGUF with a prediction layer one rollback copy of the recurrent state per drafted token and that layer's cache in f16), so change the two together. The need is judged against a budget that is `estimated` until the runtime ran and `measured` from then on (`RecommendOptions.measuredTotalBytes`). ⚠️ Ranking, lexicographic: tested on a Mac like this first, then speed `ok` before `unknown` before `slow`, then model rank, harness rank, larger context; the fit grade decides whether a row is listed and what it says, it does NOT sort, so a model is offered at the largest context it fits at. ⚠️ `limited` is a statement about the MAC (no model with 12 billion parameters or more fits), judged before the disk filter and whatever the pick is; a small pick under `ok` carries `noteSmallPick`, and a model left out for disk space is named in `noteDiskDropped`, never dropped in silence. ⚠️ The word "Recommended" exists only in `headlineTested`, and the tag "Tested on a Mac like this" only on a row whose model has a `tested` entry for exactly this chip, memory size and harness. ⚠️ Speed is `ok`/`slow`/`unknown`, graded on the unrounded figure, and a chip in no chips row is `unknown`: never guess a bandwidth. ⚠️ ONE unit for every computed size a person reads: decimal GB text from `formatGb()` (`format.ts`), sent beside the bytes, and the browser never divides bytes; the one exception is the installed memory, shown as `memClassGb` (the size the Mac is sold as, 48 and not 51.5). ⚠️ Every sentence the server chooses is a `LOCAL_MODEL_LABELS`, `LOCAL_MODEL_TAG_LABELS` or `LOCAL_MODEL_INSTALL_LABELS` text with `{placeholder}` slots filled from `vars` (no apostrophe in a text: the zh-CN key is single-quoted), and each needs a zh-CN entry. Admin only in multi-user mode (403 before anything is probed). Tests: `test/local-model-*.test.ts`, `test/routes/local-model-routes.test.ts`. Window rules: `src/web/CLAUDE.md` (Run a model on this Mac). → `docs/api-reference.md` (Local model), `docs/versioning-policy.md` -**Local model install** (`POST /api/local-model/install|cancel|resume|runtime`, `DELETE /api/local-model`; `src/local-model/{install-job,runtime,ollama-client,connect,state-store,paths,developer-tools}.ts`; EXPERIMENTAL, admin only in multi-user mode on EVERY verb; guide `docs/local-models.md`): installs the chosen model on the Mac the server runs on and ends on a session of the coding agent running on it. ONE job of five idempotent steps (`runtime`, `model`, `verify`, `agent`, `connect`; `agent` only when the coding agent is missing), a pure reducer (`reduceInstallJob()`) over three tables in `install-job.ts` driven by one service per process (`getLocalModelService()`); the window polls the GET once a second, no SSE event. ⚠️ Layout only through `localModelPaths()` (all `dataPath()`): `local-model/runtime/ollama-/`, `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 leaves a record the state file does not know; a record under the reserved id that says exactly what ours would say is ours (`isSameLocalEndpoint()`). ⚠️ A job whose end could not be written (a full disk) is kept in memory (`unrecorded`): the view shows it, the next verb writes it, and `installing` in the file while this process runs no job always reads as `paused` or that failure, never as a running job. ⚠️ The runtime's pid is stored the moment it is spawned, and Stop, Remove and the boot of anything that is not a running installation end a runtime an earlier server left behind (`supervisor.stop({ previousPid })`, ours by its command line) before any file is deleted; an installed model keeps the runtime VERSION it was installed with while that folder is unpacked, and a start that fails shows as `runtime-start-failed` with Start as the next step. Stop and "Use another port" answer 409 `sessions-open` while a live session uses the model. ⚠️ Remove (409 `sessions-open` with the names while a live session uses the endpoint) undoes the connection FIRST, restores the flag only when this feature switched it on AND no other endpoint is saved, deletes only inside `local-model/` (the models only when asked), and leaves the coding agent. ⚠️ Developer tools: only `/usr/bin/xcode-select -p`, bounded; never `git`/`python3`/`xcrun` from the server (each opens a system window on such a Mac). Tests: `test/local-model-install-job.test.ts` (every state with every event), `-install-service`, `-install-e2e` (real modules over `test/fixtures/fake-ollama.ts`), `-state-store`, `-connect`, `test/cli-install-runner.test.ts`, `test/custom-model-hosts-writer.test.ts`, `test/routes/local-model-routes.test.ts`, `test/routes/local-model-launch-guard.test.ts`. → [architecture-invariants#local-model-install](docs/architecture-invariants.md#local-model-install), `docs/local-models.md`, `docs/api-reference.md` (Local model) +**Local model install** (`POST /api/local-model/install|use|cancel|resume|runtime|agent`, `DELETE /api/local-model`, `DELETE /api/local-model/models`; `src/local-model/{install-job,runtime,ollama-client,connect,state-store,paths,developer-tools}.ts`; EXPERIMENTAL, admin only in multi-user mode on EVERY verb; guide `docs/local-models.md`): installs the chosen model on the Mac the server runs on and ends on a session of the coding agent running on it. ONE job of five idempotent steps (`runtime`, `model`, `verify`, `agent`, `connect`; `agent` only when the coding agent is missing), a pure reducer (`reduceInstallJob()`) over three tables in `install-job.ts` driven by one service per process (`getLocalModelService()`); the window polls the GET once a second, no SSE event. ⚠️ A LIBRARY with ONE model in use: the store may hold several models and `LocalModelStateFile.library` lists the ones that passed the check (written by the service when `verify` passes, carried by the reducer, dropped by `remove`; an `installed` file without it counts its one model, `libraryOf()`), but the endpoint record lists ONE model, the runtime holds one and only the state's model is ever launched: never widen the record to every checked model. `install` from `installed` adds a model (the earlier one stays: `replacedModelFor()` never names a library model), `use` switches to a checked one (a job of `USE_JOB_STEPS`, `verify` then `connect`; the stored digest is compared before anything is loaded, else `model-changed`), and both answer 409 `sessions-open` while a live session uses the endpoint; until such a job ends `installed`, no session starts (`launchRefusal()` answers `job-unfinished`). `DELETE /api/local-model/models` deletes ONE model by its NAME (`LOCAL_MODEL_ID_PATTERN`, never a path), only a name of the server's own listing, only through the runtime's own delete, never the model of the installation or of the current job (409 `model-active`), and it is no reducer event: its row is the `delete-model` action of `INSTALL_STATE_TABLE`. ⚠️ The view's `library` (state entries joined with the runtime's `/api/tags`: `ready`, `changed`, `missing`, `unverified`) and `store` (the blob folder's size, `measureModelStore()`) are cached (5 s, 30 s), refreshed in the background and thrown away by every verb and step that changes them (`forgetStore()`): never ask the runtime or the disk on the one-second poll, and a runtime that does not answer keeps the last list. ⚠️ Layout only through `localModelPaths()` (all `dataPath()`): `local-model/runtime/ollama-/`, `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`. ⚠️ The catalogue lists four harnesses (opencode, claude, pi, deepseek; a row may also carry `readyMark`, and what each row rests on is the HARNESSES table of the catalogue header). A LAUNCHER (`discovery.launcherProfile`, DeepSeek Harness) is never `installable`: its install command leaves it with nothing to launch, so it is offered only where the Run menu can already start it. `readyMark` holds only a mark that was MEASURED on a custom model session (Pi has none on purpose). ⚠️ The coding agent is chosen PER SESSION: the launch check asks about the endpoint and the model, never the agent; `install.harnesses` (`buildHarnessRows()`, pure) lists every catalogue harness the model in use can be run with (`ready`, `installable`, `installing`, `setup-needed`), and `install.cli` stays the DEFAULT for a client that names none (never an agent that is gone while another can be started). ⚠️ `POST /api/local-model/agent` installs one more agent beside an installed model and is NOT a job or a reducer event: the state stays `installed` whatever the installer does (a failed `npm install` must never refuse a session on a model that works), same slot, stock command and audit line as the `agent` step, one at a time, the outcome in memory only. ⚠️ 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 leaves a record the state file does not know; a record under the reserved id that says exactly what ours would say is ours (`isSameLocalEndpoint()`). ⚠️ A job whose end could not be written (a full disk) is kept in memory (`unrecorded`): the view shows it, the next verb writes it, and `installing` in the file while this process runs no job always reads as `paused` or that failure, never as a running job. ⚠️ The runtime's pid is stored the moment it is spawned, and Stop, Remove and the boot of anything that is not a running installation end a runtime an earlier server left behind (`supervisor.stop({ previousPid })`, ours by its command line) before any file is deleted; an installed model keeps the runtime VERSION it was installed with while that folder is unpacked, and a start that fails shows as `runtime-start-failed` with Start as the next step. Stop and "Use another port" answer 409 `sessions-open` while a live session uses the model. ⚠️ Remove (409 `sessions-open` with the names while a live session uses the endpoint) undoes the connection FIRST, restores the flag only when this feature switched it on AND no other endpoint is saved, deletes only inside `local-model/` (the models only when asked), and leaves the coding agent. ⚠️ Developer tools: only `/usr/bin/xcode-select -p`, bounded; never `git`/`python3`/`xcrun` from the server (each opens a system window on such a Mac). Tests: `test/local-model-install-job.test.ts` (every state with every event, the library rows), `-install-service`, `-install-e2e` (real modules over `test/fixtures/fake-ollama.ts`, a second model included), `-state-store`, `-connect`, `test/cli-install-runner.test.ts`, `test/custom-model-hosts-writer.test.ts`, `test/routes/local-model-routes.test.ts`, `test/routes/local-model-launch-guard.test.ts`. → [architecture-invariants#local-model-install](docs/architecture-invariants.md#local-model-install), `docs/local-models.md`, `docs/api-reference.md` (Local model) **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 `.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/.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//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/.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//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. diff --git a/docs/api-reference.md b/docs/api-reference.md index e57e944fc..0a1561ba4 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -1716,7 +1716,7 @@ Only replies from `allowedUserIds`, in `guildId`, inside a thread the bridge cre ## Local model -`GET /api/local-model` looks at the machine the server runs on and says which local coding model fits it, with which agent CLI, and where an install of one stands. Five verbs install a model, pause and continue the install, stop and start the model runtime, and remove everything again. It covers Apple silicon macOS only; anywhere else the GET is still a `200`, with verdict `none`, and nothing can be installed. How it works: [local-models.md](local-models.md). +`GET /api/local-model` looks at the machine the server runs on and says which local coding model fits it, with which agent CLI, where an install of one stands and which models are on the machine. Eight 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, install one more agent CLI beside an installed model, and remove everything again. It covers Apple silicon macOS only; anywhere else the GET is still a `200`, with verdict `none`, and nothing can be installed. How it works: [local-models.md](local-models.md). **Experimental.** These routes and their payloads are labeled experimental: the fields below, the states, the error codes and the sentences may change or go in a MINOR or PATCH release (see [versioning-policy.md](versioning-policy.md), "What SemVer does NOT cover", item 3). Do not build on their shape yet. @@ -1725,11 +1725,14 @@ Every route is admin only in multi-user mode (`403 FORBIDDEN`, answered before a | Method | Path | Body | Notes | | -------- | -------------------------- | ------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `GET` | `/api/local-model` | none | `200` with `{ hardware, recommendation, catalogUpdated, install }`. `500 INTERNAL_ERROR` only when the recommendation itself fails, never for a failed probe. | -| `POST` | `/api/local-model/install` | `{ modelId, cli, context, acknowledgedBytes }` | Starts the one install job and answers at once; the job runs on in the server. `400 INVALID_INPUT` when the three choices are not a row the server offers right now. `409`: `job-running`, `already-installed`, `size-changed`. | +| `POST` | `/api/local-model/install` | `{ modelId, cli, context, acknowledgedBytes }` | Starts the one install job and answers at once; the job runs on in the server. With a model installed it adds another one: the earlier model stays on disk, and the new one is the model in use when the job ends. `400 INVALID_INPUT` when the three choices are not a row the server offers right now. `409`: `job-running`, `already-installed` (this model at this context is the one in use), `size-changed`, `sessions-open`. | +| `POST` | `/api/local-model/use` | `{ modelId }` | Switches to a model of `install.library` whose `actions` list `use`. A job of the two steps `verify` and `connect`; nothing is downloaded. `400 INVALID_INPUT` when the model is not a checked model of the library, or no coding agent for it is installed. `409`: `job-running`, `wrong-state` (nothing is installed), `already-installed`, `sessions-open`. | | `POST` | `/api/local-model/cancel` | none | Interrupts the step in flight; the job is `paused` at that step. `409 wrong-state` when no job is running. | | `POST` | `/api/local-model/resume` | `{ newPort? }` (may be absent) | Continues a `paused` or `failed` job at the same step. `newPort: true` chooses another port first. `409`: `job-running`, `wrong-state`, `sessions-open` (`newPort` while live sessions use the model). | | `POST` | `/api/local-model/runtime` | `{ action: "start" \| "stop", newPort? }` | `stop` stops the runtime and keeps it from starting at the next boot; `start` undoes that. `newPort: true` with `start` chooses another port and moves the saved endpoint to it. `409`: `job-running`, `wrong-state` (nothing is installed), `sessions-open` (`stop` or `newPort` while live sessions use the model: their agents keep calling the address they were started with). | -| `DELETE` | `/api/local-model` | `{ removeModel? }` (may be absent) | Stops the runtime, removes the saved endpoint, restores the settings flag when this feature switched it on and no other endpoint is saved, deletes the runtime; the model only with `removeModel: true`. `409`: `job-running`, `sessions-open`. | +| `DELETE` | `/api/local-model` | `{ removeModel? }` (may be absent) | Stops the runtime, removes the saved endpoint, restores the settings flag when this feature switched it on and no other endpoint is saved, deletes the runtime; the downloaded models (all of them) only with `removeModel: true`. With nothing installed it is what deletes the models an earlier Remove kept. `409`: `job-running`, `sessions-open`. | +| `DELETE` | `/api/local-model/models` | `{ modelId }` | Deletes ONE model from the store, through the runtime. `modelId` is a model name (`name:tag`) of `install.library` whose `actions` list `delete`, never a path. `400 INVALID_INPUT` for a name that is not on this machine. `409`: `job-running`, `wrong-state` (nothing is installed), `model-active` (the model of the installation or of the current job), `runtime-not-running`. `500 OPERATION_FAILED` when the runtime did not delete it. | +| `POST` | `/api/local-model/agent` | `{ cli }` | Installs one more agent CLI beside an installed model and answers at once; the installer runs on in the server and `install.harnesses` says how it stands. `cli` is a row of `install.harnesses` whose `actions` list `install`; the server runs that row's `installCommand`, the release's own stock command, never text from the request. It is not a job: `install.state` stays `installed` whatever the installer does, sessions are not refused, and no live session is looked at. An agent that can already be started answers `200` with the view as it is. `400 INVALID_INPUT` for a CLI that is not a row of `install.harnesses`, or one that one command does not make startable (`setup-needed`). `409`: `job-running` (a job, or another agent install, is running), `wrong-state` (nothing is installed). | Every verb answers `{ success: true, data: }` 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: @@ -1747,9 +1750,9 @@ Every verb answers `{ success: true, data: }` with the `install` objec } ``` -`data.reason` is `job-running`, `already-installed` (remove the installed model first), `size-changed` (with `expectedBytes` and `expectedGb`), `sessions-open` (with `sessions`, the names of the live sessions that use the model) or `wrong-state`. A `409 CONFLICT` without `data` means a config file that must not be overwritten (`settings.json` or `custom-model-hosts.json` does not parse); `error` names the fix. +`data.reason` is `job-running`, `already-installed` (the model is the one in use already), `size-changed` (with `expectedBytes` and `expectedGb`), `sessions-open` (with `sessions`, the names of the live sessions that use the model), `wrong-state`, `model-active` (a delete of the model in use: switch first, or remove everything) or `runtime-not-running` (a delete while the runtime is down: start it first). A `409 CONFLICT` without `data` means a config file that must not be overwritten (`settings.json` or `custom-model-hosts.json` does not parse); `error` names the fix. -`POST /api/local-model/install` also answers `400` when a saved model endpoint already uses the id `local-ollama` and was not created by this feature: rename or delete that endpoint first. The check is made before anything is downloaded. Every `400` the install names, and the `507 OPERATION_FAILED` of a first install on a full disk, carries its sentence as `data.message` (`{ text, vars }`, like a `409`), so a client can print it in its own language. +`POST /api/local-model/install` also answers `400` when a saved model endpoint already uses the id `local-ollama` and was not created by this feature: rename or delete that endpoint first. The check is made before anything is downloaded. Every `400` the feature names, the `507 OPERATION_FAILED` of a first install on a full disk and the `500 OPERATION_FAILED` of a delete the runtime refused carry their sentence as `data.message` (`{ text, vars }`, like a `409`), so a client can print it in its own language. **Consent is bound to the size shown.** `acknowledgedBytes` must be the `installBytes` of the chosen row: the model download plus the runtime download while the runtime is not unpacked yet. The server compares it with its own total before anything starts and answers `409 size-changed` when they differ, so a click confirms exactly the size the person saw. @@ -1775,22 +1778,26 @@ A probe that fails is an answer, not an error: a server that is not on macOS, or - `tag?`: `{ kind, label }`, the one tag of the row. `kind` is `tested`, `best`, `tight`, `slow` or `small`; - `fit`: `good` (up to 0.85 of the budget) or `tight` (up to 0.98), with its label `fitLabel`; - `speed`: `ok`, `slow` (under 10 tokens per second) or `unknown`, with `speedLabel` (absent for `ok`) and `tokensPerSecond` (rounded to one decimal, absent for `unknown`; `speed` is decided on the unrounded figure, so `10` can be `slow`). `unknown` means the chip is in no row of the catalogue's chip table, or the catalogue has no speed figure for the model: a speed is never guessed; - - `tested`: we ran this model with this CLI on a Mac like this one (same chip, same memory size). Its `tokensPerSecond` is then the speed measured in that run. + - `tested`: we ran this model with this CLI on a Mac like this one (same chip, same memory size). Its `tokensPerSecond` is then the speed measured in that run; + - `library?`: the model at this context is a checked model on this machine already (see `install.library`). `active`: it is the model in use. `ready`: `POST /api/local-model/use` switches to it, and nothing is downloaded. Absent otherwise. - `budgetBytes` and `budgetGb`: the memory a model may use on this Mac (`0` for `not-macos` and `intel`, where none is computed). `budgetSource` is `estimated` (computed from the installed memory: all there is before the runtime ran), `wired-limit` (taken from `iogpu.wired_limit_mb`) or `measured` (the device total the runtime itself printed when it started; the GET uses it from the first install on). - `catalogUpdated`: ISO date on which the catalogue's rows were last checked. - `install`: where the install stands and what can be done about it. - `state`: `idle` (nothing installed, no job), `installing`, `paused` (Cancel or a server restart stopped the job; it never continues without a click), `failed` or `installed`. - - `step` and `steps[]` (present with `installing`, `paused` and `failed`): the step the job is in or stopped in, and the steps of this job in order, out of `runtime`, `model`, `verify`, `agent`, `connect`. `agent` is absent from a job whose agent CLI was already installed. + - `step` and `steps[]` (present with `installing`, `paused` and `failed`): the step the job is in or stopped in, and the steps of this job in order, out of `runtime`, `model`, `verify`, `agent`, `connect`. `agent` is absent from a job whose agent CLI was already installed. A switch (`POST .../use`) is the two steps `verify` and `connect`. - `progress` (while a download runs, and as it stood on a paused or failed job): `completedBytes`, `totalBytes`, `completedGb`, `totalGb`, and while it moves `bytesPerSecond`, `mbPerSecond` (whole decimal megabytes per second as text) and `secondsLeft`. During the `model` step both byte figures come from the download's own lines, not from the catalogue. - `error` (with `failed`; with `installed` only when something blocks a session): `{ code, message: { text, vars? }, next, nextModel?, detail? }`. `next` is the ONE action that helps: `retry` (`POST .../resume`), `other-port` (`POST .../resume` or `.../runtime` with `newPort: true`), `next-model` (choose again; `nextModel` is `{ modelId, modelLabel }` when the server knows one), `start` (`POST .../runtime`) or `remove` (`DELETE`). `detail` is raw text for the curious (an installer's last lines), not translated. - - `model` (`{ id, label }`), `cli`, `cliLabel`, `context`: the choice of the job or of the installation. `port`: the runtime's loopback port, once chosen. + - `model` (`{ id, label }`), `cli`, `cliLabel`, `context`: the choice of the job or of the installation. With `installed`, `cli` is the DEFAULT agent CLI, the one a client that names none starts: the agent of the installation while it can be started, else the first row of `harnesses` that can. `port`: the runtime's loopback port, once chosen. - `running`: the runtime Codeman started is alive right now. A runtime the person stopped is `running: false` with no `error`. - `loaded` (with `installed`): what the runtime reported when the job checked the loaded model: `context`, `memoryBytes`, `memoryGb`, `onGpu`. - - `endpointId` (with `installed`): the saved model endpoint a session is started on, with `cli`. + - `endpointId` (with `installed`): the saved model endpoint a session is started on, with `cli` or with any row of `harnesses` that offers `start-session`. - `developerTools`: `present`, `missing` (the Mac has no Xcode or Command Line Tools, so macOS will offer to install them the first time an agent runs `git` or `python3`) or `unknown`. - `runtime`: `{ version, installed, downloadBytes, downloadGb, unpackedGb }`. `downloadBytes` is `0` once the runtime is unpacked. - `folder`: the absolute folder everything of this feature is kept in. - - `actions[]`: exactly what the server accepts right now, out of `install`, `cancel`, `resume`, `start`, `stop`, `remove` and `start-session` (not a route here: start a session on `endpointId`; offered only while the runtime runs and nothing blocks a session). + - `library[]`: the models on this machine, the model in use first. Empty with `idle` (no runtime is there to ask). Each row: `id` (the name the runtime lists, `name:tag`), `label`, `context?` (the context it was checked with), `sizeBytes?` and `sizeGb?` (its size on disk as the runtime reports it, else as recorded when it was checked), `active` (it is the model in use), `verified` (Codeman checked it), `status` and `actions[]`. `status` is `ready`, `changed` (checked, and the runtime now reports another digest under the name), `missing` (checked, and the runtime no longer lists it) or `unverified` (the runtime lists it and Codeman never checked it: a model an earlier Remove kept, the model of a job that has not passed its check, or one another program pulled through the runtime's port). `actions` is what the server accepts for THAT row right now, out of `use` and `delete`; `delete` needs the running runtime, and a name that is not a plain `name:tag` is listed with no action. + - `store?`: `{ bytes, gb }`, what the model store takes on disk (the files of its blob folder, so shared layers count once and an interrupted download is included). Present in every state once it was measured; cached for about thirty seconds. + - `harnesses[]`: the agent CLIs a session on the model in use can be started with, the default one first, then by the catalogue's rank. The agent is chosen per session: a session is started with `mode` set to a row's `cli` and `customModel: { endpointId, modelId }`. Empty in every state but `installed`. Each row: `cli`, `cliLabel`, `default` (true on the row `install.cli` names), `status`, `installCommand?` (with `installable` and `installing`: the literal command `POST .../agent` runs), `slowFirstTurn?` (the first answer takes minutes), `readyMark?` (a literal without white space that the agent's composer draws once it takes input: what a script waits for on `GET /api/sessions/:id/wait-output` before it sends the first prompt; absent for an agent with no measured mark. A mark says the composer is there, not that nothing else is: DeepSeek Harness with dsh-TUI 0.19 opens a setup guide whose cursor is its `❯` too until a person has finished it once, so a script reads the pane after the mark), `error?` (`{ message, detail? }`: the last install of this agent through `POST .../agent` failed; kept in memory only) and `actions[]` (`start-session`, `install`). `status` is `ready` (it can be started, the answer the Run menu gets), `installable` (not on this machine, and `POST .../agent` installs it), `installing` (that install is running; the model stays as it is) or `setup-needed` (not startable, and one command does not make it so: DeepSeek Harness without a terminal profile, or a command that needs an administrator; set it up from the Run menu or a terminal). + - `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), `install-agent` (only while a row of `harnesses` offers `install`) and `start-session` (not a route here: start a session on `endpointId`; offered only while the runtime runs, nothing blocks a session and, when `harnesses` is known, at least one agent CLI can be started). Error codes (`install.error.code`), each with one next step: @@ -1800,9 +1807,11 @@ Error codes (`install.error.code`), each with one next step: | `start` | `runtime-stopped` (the runtime crashed three times in a row and is no longer restarted) | | `other-port` | `port-in-use` | | `next-model` | `model-not-found`, `does-not-fit`, `no-tool-call` | -| `remove` | `model-changed` (the model's digest is no longer the one stored at install time; a session on it is refused) | +| `remove` | `model-changed` (the model's digest is no longer the one stored when it was checked; a session on it is refused, and so is a switch to it) | -Starting a session on the saved endpoint goes through the existing custom model launch (`POST /api/quick-start` with `customModel`, or `POST /api/sessions/:id/custom-model`). For the endpoint this feature manages both answer `OPERATION_FAILED` with one sentence while the runtime is not running, when the model's digest changed, or for a model other than the installed one. +Two codes are only ever the reason of a refused session start, never on `install.error`: `runtime-not-running` (the person stopped the runtime) and `job-unfinished` (a job that changes the model in use is running or stopped: nothing is started until it ended `installed` again). + +Starting a session on the saved endpoint goes through the existing custom model launch (`POST /api/quick-start` with `customModel`, or `POST /api/sessions/:id/custom-model`). For the endpoint this feature manages both answer `OPERATION_FAILED` with one sentence while the runtime is not running, while a job that changes the model in use is running or stopped, when the model's digest changed, or for a model other than the one in use. Every computed size a person reads travels twice: as bytes, and as decimal GB text without the unit (`"needGb": "29.6"`). Show the text and add the unit; do not divide the bytes yourself, so one request never shows two different roundings. The installed memory is the exception named above: show `memClassGb`. diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index d280b4773..dcd91b0cd 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -714,17 +714,27 @@ Either way the actual launch (`runCustomModelEntry`) routes through `run()` itse **The coding agent** is installed by `runInstallCommand()` of `src/cli-install-runner.ts`, the runner extracted from `cli-registry-routes.ts` (whose route keeps its behaviour and re-exports `installEnv`/`npmGlobalPrefixWritable` for its tests). ⚠️ The job runs only `stockInstallCommand(id)`, read from the COMPILED `STOCK_CLIS`, never the registry merged with `clis.json` (the route's own command still comes from the merged registry, behind its `cliManagementEnabled` gate): this path has no such gate, so an override file must not be able to change what it runs. `HarnessFact.installCommand` is that same string, which is what the consent screen prints. ⚠️ Only for a harness row of the catalogue, only after the consent click, admin only, audited (`cli_install` with `via: 'local-model'`). ⚠️ ONE install slot per CLI id (`tryBeginCliInstall()`), shared by the route and the job. ⚠️ After the installer, successful or not, the resolver caches are forgotten (`forgetResolvedCli()`) and the binary is looked for again: a pre-install miss is negative-cached for up to five minutes, and "Start a session" would otherwise answer "not installed". Still missing is `harness-failed`; found is success whatever the exit code. The runner takes an abort signal that kills the installer's process GROUP. +**The coding agent is chosen per session.** The launch check (`launchRefusalMessage()`) asks about the endpoint and the model, never about the agent, so any CLI with a custom model recipe could always be started on `local-ollama` from the Run menu. What the feature adds is the list: `install.harnesses` (`buildHarnessRows()`, pure) is every catalogue harness that is not gated, has a `HarnessFact`, works at the model's context and accepts its name, each `ready`, `installable`, `installing` or `setup-needed`. ⚠️ `install.cli` stays the DEFAULT agent for a client that names none (the 1.50.0 skill reads only that field): the agent of the installation while it can be started, else the first row that can, so it never names an agent that is gone while another is there. ⚠️ `start-session` is listed only while a row is `ready`, once the rows are known; rows that could not be built (the registry threw) are "unknown", never "none", and a session is still offered. ⚠️ A LAUNCHER (`discovery.launcherProfile`, DeepSeek Harness) is never `installable`: its stock command installs the launcher and leaves it with nothing to launch, and the profile it needs is third-party code behind the privileged grant of `POST /api/deepseek/install-profile`. The rule reads the entry's data, never its id, and such an agent is offered only once the Run menu can start it. ⚠️ `POST /api/local-model/agent` (`installAgent()`) is NOT a job and NOT an event of the reducer: a job would put a working model into `failed` on an `npm` error and refuse every session until it was cleared. It runs `installCodingAgent()`, the function the `agent` step runs (same slot, same compiled command, same audit line), in the background; the state file never hears of it, the outcome lives in memory (`agentInstall`, `agentFailures`), one installer at a time, and `shutdown()` aborts it. A harness row may carry `readyMark` (a literal the composer draws once it takes input, for a script's `wait-output`): only a MEASURED mark goes there, the registry's `composerReadyMark` stands in when the row has none, and Pi has none on purpose (its composer rules are painted before it takes Enter, measured on 1.5.2). + **The connection** (`connect.ts`): one record `{ id: 'local-ollama', label: 'This Mac', baseUrl, authStyle: 'bearer', models: [], defaultModelId, defaultContextLength: }`, validated by `CustomModelHostSchema` and written through `mutateCustomModelHosts()`, then `customModelEndpointsEnabled: true` through `mutateSettingsFile()`, with the flag's previous value kept in the state file (a second connect must hand the FIRST value back, or Remove could never switch the flag off again). ⚠️ `defaultContextLength` is load-bearing: the runtime's model listing states no context, so this field is what reaches the coding agent as its limit. ⚠️ The id is reserved: `POST /api/model-endpoints` refuses it, and a record with it that this feature did not write is never overwritten (`endpoint-id-taken`). ⚠️ While the state file records the connection, the record is MANAGED (`isManagedLocalEndpoint()`): `PUT`/`DELETE /api/model-endpoints/local-ollama` are refused, discover answers the stored list without a request, and the sweep skips it, so a model somebody pulled through the port never becomes launchable and the URL never leaves the supervised port. A hand-made record with that id (no state) stays an ordinary endpoint. `local-model.json` is not a config-backup file while the endpoint store and the settings are, so a restore can take the record away under an installed model: `boot()` puts the RECORD back when it is gone (`ensureLocalEndpoint()`, never replacing one that is there) and leaves the settings flag as it finds it. ⚠️ The runtime's port has NO authentication, and its CORS allowlist always admits `localhost` origins: any local process or localhost page can pull, create or delete models there. That is the reason for the three rules above and for the digest. **The digest** `/api/tags` reported after the check is stored. ⚠️ Before a session starts on the managed endpoint (`LocalModelService.launchRefusalMessage()`, asked by BOTH launch paths in `session-routes.ts`: `POST /api/sessions/:id/custom-model` and the quick-start `customModel` branch, before anything is written or created) and at boot it is read again: a different digest, a model that is gone, a runtime Codeman did not start, or a model id other than the installed one refuses the launch with one sentence. The view shows what blocks a session as `error` while the state stays `installed`; it is found live and never stored. Boot recovery of an already running session (`_rebuildCustomModelEnv`) is not guarded: the pane exists and keeps its environment. **Remove** stops the child, undoes the connection FIRST (a refused write of the endpoint store or of the settings rejects before any file is deleted), restores the flag only when this feature switched it on AND no other endpoint is saved, then deletes the runtime and the private home and, when asked, the models (`removeLocalModelFiles()`, which refuses any path outside the root). ⚠️ While a live session uses the endpoint the answer is 409 `sessions-open` with the session names (`sessionNamesUsingEndpoint()`, read at the moment of the request). ⚠️ The coding agent stays: what its installer wrote is not ours to remove. +**The library** (one model in use, several on disk). `LocalModelStateFile.library` lists every model that PASSED the check through Codeman: `{ id, label, context, digest, bytes?, cli?, verifiedAt? }`, no path. The service upserts the entry as a fact when `verify` passes (where it stores the digest); the reducer only carries the list, through every event but `remove`. An `installed` file an earlier build wrote has no list: `libraryOf()` then counts its one model, and an install or a switch from it writes that model into the list before the state's `model` member becomes the new job's. ⚠️ ONE model is in use: the endpoint record still lists exactly one model, the runtime still holds one (`OLLAMA_MAX_LOADED_MODELS=1`), and `launchRefusalMessage()` still starts only the state's model with its stored digest. Never widen the record to "every checked model": the port has no login, and two sessions on two models would make the runtime swap 20 GB on every request. + +⚠️ Three verbs, each a row of the tables and never a special case. **Add**: `install` is also listed for `installed` in `INSTALL_TRANSITIONS`; the job is the same five steps, and the earlier model stays (the `replacedModelId` rule, `replacedModelFor()`, never names a model of the library: only an unchecked or partial download is deleted as a leftover). **Switch**: the event `use` (`installed`, `paused`, `failed`) starts a job of `USE_JOB_STEPS` (`verify`, `connect`) for a library entry, carrying its stored digest in `model.digest`; `stepVerify` compares that digest BEFORE it loads anything and again after the check, and a different or missing one fails with `model-changed`. A switch has no `model` step, so the leftovers of the job it replaced are deleted at the start of `verify` (`dropReplacedModel()`). Its coding agent is the entry's own when that is installed now, else the state's (`recheckCliInstalled()`), else a 400: a job that stopped while it installed another agent leaves the state naming one that is not there. **Delete** (`deleteLibraryModel()`) moves no state, so it is not an event; whether a state allows it is the `delete-model` action of its `INSTALL_STATE_TABLE` row. It takes a model NAME (the request schema and the service both hold it to `LOCAL_MODEL_ID_PATTERN`), requires that name in the server's own listing (the library or the runtime's tags), goes through the runtime's `/api/delete` only (never a file delete, never a path), and trusts only the runtime's answer that the model is gone, never an empty listing. ⚠️ It refuses the model of the installation or of the current job (`model-active`), a running job, and a runtime that is down or does not answer (`runtime-not-running`). + +⚠️ `install` from `installed` and `use` are refused with 409 `sessions-open` while live sessions use the endpoint, like Stop: both change which model the runtime holds and often restart it for another context. ⚠️ While a job that started from `installed` is unfinished (`installing`, `paused`, `failed`) the launch check refuses every start with `job-unfinished` (a code only a refused start says, like `runtime-not-running`), because it wants the state `installed`; the view then offers `use` on the other checked models, which is the way back. A switch is never offered for the model of the stopped job itself: Resume goes on with that one. ⚠️ Both verbs undo a Stop (`undoStop()`, `runtime.enabled` back to true) once their job started: the job runs the runtime, and a model that is in use and running must also start with the server again. + +**The view's two listings.** `install.library` (`buildLibraryRows()`, pure) joins the state's entries with what the RUNNING runtime lists (`/api/tags`): `ready`, `changed` (another digest under the name), `missing` (no longer listed) for a checked model, `unverified` for anything else the runtime lists (a model an earlier Remove kept, the model of a job that has not passed its check, one another program pulled through the unauthenticated port), which can be deleted and never switched to. A name that is not a plain `name:tag` is listed with no action. `install.store` is `measureModelStore()`: the sizes of the regular files directly in `models/blobs`, nothing followed, nothing outside the root. ⚠️ Neither is asked for on the one-second poll: `tagsCache` (5 s) and `storeCache` (30 s) are refreshed in the background, only a figure that was never read is waited for (300 ms at most), a runtime that does not answer keeps the last list (or every checked model would read as `missing` for a moment), and every verb and finished step that changes the store throws both away (`forgetStore()`). ⚠️ In `idle` there is no runtime to ask, so the list is empty and only `store` says that an earlier Remove kept models; `remove` is listed there only while it holds something, and `DELETE /api/local-model { removeModel: true }` is what frees it. `Candidate.library` (`active`, `ready`) is added by the ROUTE (`markLibraryCandidates()`), never by `recommend()`, which stays pure and knows nothing of what is installed. + **Developer tools**: `checkDeveloperTools()` runs ONLY `/usr/bin/xcode-select -p`, bounded, on darwin. ⚠️ Never `git`, `python3` or `xcrun` from the server: on a Mac without the tools each opens a system window. The view carries `developerTools`, and the window prints the one sentence when it is `missing`. **The endpoint store's writer** (`src/custom-model-hosts-writer.ts`): `mutateCustomModelHosts(fn)` is the shape of `mutateSettingsFile()`: one promise chain, a random temp name, a refusal (409) to write over a file that does not parse. ⚠️ EVERY writer of `custom-model-hosts.json` goes through it: the four endpoint routes, the sweep and the connect step (`test/custom-model-hosts-writer.test.ts` fails a `writeCustomModelHosts()` call anywhere else in `src/`). The routes used to read, change and write on their own through one shared temp name, so two creates sent together lost a record or failed, and a file that did not parse was read as `[]` and replaced by one record, with every other endpoint's API key gone. ⚠️ A route's check runs INSIDE the callback, and discover splices onto the record as it is after the probe, not the copy read before it. -Tests: `test/local-model-install-job.test.ts` (the tables and the reducer, every state with every event), `test/local-model-install-service.test.ts`, `test/local-model-install-e2e.test.ts` (the real modules over the fake runtime), `test/local-model-state-store.test.ts`, `test/local-model-connect.test.ts`, `test/custom-model-hosts-writer.test.ts`, `test/cli-install-runner.test.ts`, `test/routes/local-model-routes.test.ts`, `test/routes/local-model-launch-guard.test.ts`, and the runtime's own `test/local-model-runtime.test.ts`, `test/local-model-ollama-client.test.ts`. The fake runtime is `test/fixtures/fake-ollama.ts`. +Tests: `test/local-model-install-job.test.ts` (the tables and the reducer, every state with every event, the library rows), `test/local-model-install-service.test.ts` (the three library verbs, the agent install and the two caches included), `test/local-model-install-e2e.test.ts` (the real modules over the fake runtime: install A, add B, switch back, delete B), `test/local-model-state-store.test.ts`, `test/local-model-connect.test.ts`, `test/custom-model-hosts-writer.test.ts`, `test/cli-install-runner.test.ts`, `test/routes/local-model-routes.test.ts`, `test/routes/local-model-launch-guard.test.ts`, and the runtime's own `test/local-model-runtime.test.ts`, `test/local-model-ollama-client.test.ts`. The fake runtime is `test/fixtures/fake-ollama.ts`. ### Approvals Inbox diff --git a/docs/custom-model-endpoints.md b/docs/custom-model-endpoints.md index 31f5de007..187bf5f07 100644 --- a/docs/custom-model-endpoints.md +++ b/docs/custom-model-endpoints.md @@ -578,6 +578,13 @@ automatically). Results: - **Claude, opencode, Pi, Grok, OMP** — verified: a real "hello world" reply came back through the endpoint. + Pi's model entry carries the endpoint's context length when one is known + (`contextWindow`, the full window, and `maxTokens`, a quarter of it and + 8,192 at most). Without it Pi takes an unknown model for a 128,000-token + one (its footer reads `/128k`) and asks for 16,384 reply tokens, so on a + smaller window it never compacts before the server's real window is full. + Measured on pi 1.5.2: with both fields `pi --list-models` shows the window + and the request carries `max_tokens: 8192`. - **Codex** — the config is structurally correct, and against a llama-swap server that DOES answer `/v1/responses` (confirmed live: a plain, no-tool-call chat turn returned a real reply), the picture is more diff --git a/docs/local-models.md b/docs/local-models.md index 485c40cd4..6f9578168 100644 --- a/docs/local-models.md +++ b/docs/local-models.md @@ -4,7 +4,9 @@ Codeman can set up a coding model that runs on the Mac the server runs on: no ac no API key, and the session's prompts and files go to a model on this Mac rather than a cloud provider (the coding agent itself may still check for updates). One button on the welcome screen opens a window that shows what fits this Mac, installs the choice and ends on a coding agent session that -runs on it. +runs on it. The same window then lists the models that are on this Mac with what each takes +on disk, adds another one, switches between them and deletes one +([The models on this Mac](#the-models-on-this-mac)). **Experimental.** The routes and their payloads may change in any release ([versioning-policy.md](versioning-policy.md), "Experimental local model routes"). Apple @@ -103,7 +105,9 @@ for a row it does not know. Its answer for each memory size is then pinned in ## What an install does, in order The person picks a model, presses "Install on this Mac" and confirms the sizes once. The -server then runs one job of five steps. The window polls `GET /api/local-model` once a +server then runs one job of five steps. The same job adds a second model to an installed one +("Add a model"): the runtime is there already, so it downloads the model alone, and the +earlier model stays on disk. The window polls `GET /api/local-model` once a second; the job runs in the server, so the window can be closed. | Step | What it does | @@ -206,6 +210,66 @@ This step does not need the `cliManagementEnabled` setting. That setting gates t CLI management surface; this path can only ever run one vetted command for one catalogue row. +### Which coding agents, and what each was measured with + +The coding agent is chosen **per session**. An installed model lists every agent it can be +run with (`install.harnesses` of the GET), and the window shows them as chips under "Start a +session with": the agent the model was installed with is the default, and the choice is +remembered on the device. The launch check asks about the endpoint and the model, never about +the agent, so a session with one agent and a session with another run on the same model. + +| Agent | First request (its fixed prompt) | Installed from the window | What was seen, and where | +| ---------------- | ------------------------------------------------------------------- | ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | +| OpenCode | about 8,100 tokens, 11 tools (v1.19.5; more with user-level skills) | yes | A tool round trip on a Mac with a real model (the catalogue's tested entry), and on a scripted endpoint for v1.19.5 | +| Claude Code | about 36,000 tokens, so the first answer takes minutes | yes | Shipped in 1.50.0; not measured again for this table | +| Pi | 2,096 tokens, 4 tools (v1.5.2) | yes | A complete tool round trip against a real model: tool call, tool result, final answer, the file on disk | +| DeepSeek Harness | about 7,650 tokens, 25 tools (0.1.1-rc.2); 6,983, 27 tools (0.2.2-rc.1) | **no** | A tool round trip on a scripted endpoint only, on both versions. Against a real model the launch and the model name reached the runtime, and no turn finished | + +How to read the last column: the runs of 2026-11-10 were made on **Linux**, through Codeman's +own custom model launch, against Ollama 0.13.0 with a 1.7B model on a CPU ("a real model") or +against a scripted endpoint that replays that runtime's stream of a tool call. A scripted run +proves the agent's side of the protocol, not what a model does with its prompt. None of them +ran on a Mac or on the pinned runtime. Every agent sent the `name:tag` model name unchanged. + +Three things follow from how the agents differ: + +- **Pi is told the model's window.** Without it Pi takes an unknown model for a + 128,000-token one and never compacts before the real window is full. Codeman now writes the + endpoint's context length into the model list it gives Pi (the custom model feature as a + whole, not only this window). Two things remain: a custom model session gives Pi a home + directory of its own, so Pi fetches its two search tools again at every start (it needs the + network for that; `PI_OFFLINE=1` skips it), and Pi asks "Trust project + folder?" in every such session when a folder above the working directory holds agent + skills, because the answer is stored in the home it does not keep. +- **DeepSeek Harness is a launcher.** `dsh` starts nothing by itself: a session needs a + terminal profile, which is third-party code with its own consent in the Run menu. Its + install command installs the launcher only, so this window never installs it. It is listed + as "Needs setup" until the Run menu can start it, and as a normal agent from then on. It is + the one agent of the four besides Claude Code that reports the end of a turn. Its custom + endpoint recipe itself was verified with a real model on a llama.cpp server + ([custom-model-endpoints.md](custom-model-endpoints.md)); what is missing is a finished turn + on this feature's runtime. On today's version (0.2.2-rc.1 with dsh-TUI 0.19.0) two more + things were seen: a setup guide opens in every session until a person has finished it once + (a person in a tab presses through it; a script must not, see the codeman skill), and its + transcript file was renamed, so Codeman's response viewer and `last-response` are empty for + a dsh session on that version until the reader learns the new name. +- **OpenCode and DeepSeek Harness send a second, small request** beside the first prompt (a + title, a side question). The runtime answers one request at a time, so it queues. + +### Installing one more agent beside an installed model + +An agent that is not on the Mac has "Not installed" on its chip. Selecting it shows the +literal install command, and the main button becomes "Install ". That click is the +consent; `POST /api/local-model/agent` then runs the same stock command under the same rules +as the `agent` step above (catalogue row only, compiled command only, admin only, audited, +one installer at a time). + +It is **not a job**. The state stays `installed` whatever the installer does: the model stays +loaded, sessions are not refused, and a session with another agent can be started while it +runs. A failed install shows the sentence and the installer's last lines under the chip, and +leaves everything else as it was. The outcome is kept in memory only, so a restart of the +server forgets a failure (the chip then simply says "Not installed" again). + ### The connection Two writes outside the local model folder, both undone by Remove: @@ -240,14 +304,17 @@ Switching the setting on also starts that five-minute sweep for every other save ## Starting a session -"Start a session" starts the coding agent on the endpoint `local-ollama` through the +"Start a session" starts the selected coding agent on the endpoint `local-ollama` through the existing custom model launch. Before a coding agent is STARTED on that endpoint the server checks, in this order: +- no job that changes the model in use is running or stopped: until an install or a switch + ended, nothing is started ("The model on this Mac is being changed and is not ready"); - the runtime child Codeman started is alive (whatever else answers on the port is not ours); -- the digest the runtime reports for the installed model is the one stored at install time; -- the model asked for is the one that was installed and checked. +- the digest the runtime reports for the model in use is the one stored when it was checked; +- the model asked for is the model in use. Another model that is on this Mac is not started + on the endpoint: switch to it in the window first. That check runs wherever a new agent process comes to be on the endpoint: a session created with the model, a model applied to a running session (which restarts its agent), the attach @@ -266,29 +333,86 @@ The codeman skill ([`skills/codeman`](../skills/codeman/SKILL.md#claude-in-the-c lets an agent in a Codeman session hand work to this model, so Claude in the cloud plans and reviews while the model on this Mac does the bulk: summaries, classification, first drafts, and files that should not leave the machine. `local_model` reads `GET /api/local-model` and says what -is installed or why nothing can run; `spawn_worker local` starts the runtime when it was -stopped (as Start does), then starts the installed coding agent on `local-ollama` through the same +is installed or why nothing can run, with the coding agents the model can be run with and the +models on the Mac; `spawn_worker local` starts the runtime when it was +stopped (as Start does), then starts the default coding agent (`install.cli`), or with +`local:` (`local:pi`, `local:deepseek`) another one that is ready, on `local-ollama` through the same quick-start launch and the same checks as above; `sendmark` gives the worker a task and reads its -answer back from a file it writes in its own folder. The skill never installs a model and never +answer back from a file it writes in its own folder. The skill never installs a model or a coding agent and never 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 default coding agent becomes the one the model was + checked with when it is still installed, else the one of the installation, else any other + agent that is on the Mac and can run the model. +- **Delete** removes one model through the runtime's own delete, after one question in the + row. The request carries the model's name, never a path, and the server deletes only a + name of its own listing. The model in use cannot be deleted: switch first, or Remove. It + needs the running runtime, so a stopped model shows no Delete until Start. + +Adding a model and switching are refused while a session uses the model ("Sessions are +still using this model"), like Stop: their coding agents were started with the other model's +name, and the runtime would swap models on every request. + +While a job that started from an installed model runs, or after it stopped (paused or +failed), no session is started: the checks above want a finished installation, and the +refusal says so (`job-unfinished`). The window +then lists the checked models with **Use**, which is the way back to the earlier one. Going +back deletes what the stopped job left of a model that never passed its check, the way a new +install does; a checked model is never deleted that way. + ## Stop, Start, Remove - **Stop** stops the runtime and keeps it from starting at the next boot. **Start** undoes - that. + that, and so does a switch to another model or the install of one: the job starts the + runtime, and the model it ends on is in use. - **Remove** stops the runtime, removes the endpoint record, switches the setting off again only when this feature switched it on and no other endpoint is saved, and deletes the - runtime and its private home. The downloaded model is deleted only when asked. The coding - agent stays. While a live session uses the model, Remove answers with the session names - instead. + runtime and its private home. The downloaded models (all of them) are deleted only when + asked. The coding agent stays. While a live session uses the model, Remove answers with + the session names instead. +- **Models a Remove kept** are not lost space: with nothing installed the window says how + much they still take and offers "Delete them", and after a new install they show in the + list as "Not checked", each with its own Delete. ## Where everything is All under the data directory (`~/.codeman`, or the instance's own): ``` -local-model.json the state file (mode 0600) +local-model.json the state file (mode 0600), with the list of checked models local-model/ runtime/ollama-0.40.15.tgz.partial the runtime archive while it downloads runtime/ollama-0.40.15/ the unpacked runtime (about 579 MB) @@ -313,9 +437,12 @@ in App Settings. | ------------ | ------------------------------------------------------------- | ---------------- | -------------------------------------- | | `idle` | stays idle | refused | install | | `installing` | becomes `paused` at the same step | becomes `paused` | cancel | -| `paused` | stays paused | refused | resume, install, remove | -| `failed` | stays failed, same error | refused | resume, install, remove | -| `installed` | stays installed; the runtime is started unless it was stopped | refused | start a session, stop or start, remove | +| `paused` | stays paused | refused | resume, install, use, delete a model, remove | +| `failed` | stays failed, same error | refused | resume, install, use, delete a model, remove | +| `installed` | stays installed; the runtime is started unless it was stopped | refused | start a session, stop or start, install (add a model), use, delete a model, remove | + +`use` and "delete a model" are listed only while a row of the list offers them, and in +`idle` "remove" is offered only while the model store still holds something. One job at a time. A job a server restart interrupted comes back paused and never continues without a click. Cancel interrupts the step in flight (the download, the pull, the request, @@ -357,6 +484,12 @@ or `python3`. on a Mac. "Recommended" appears only for the pair we ran, on the Mac we ran it on. - **The model stays in memory for ten minutes** after the last request, then the runtime unloads it. +- **One model runs at a time.** Several can be on disk; the runtime holds one, and a switch + restarts it and loads the other model (seconds to a minute). Close the sessions on the + model first: the window refuses the switch while one is open. +- **The disk figures are the runtime's.** A row's size is what the runtime reports for the + model, and the total is the size of its blob files. Neither was compared with Finder on a + Mac yet. ## Keeping the catalogue current diff --git a/docs/wiki/Codeman-Skill.md b/docs/wiki/Codeman-Skill.md index 864d141b2..1df610550 100644 --- a/docs/wiki/Codeman-Skill.md +++ b/docs/wiki/Codeman-Skill.md @@ -138,6 +138,8 @@ from your side. See [Agent CLIs](Agent-CLIs) for the run modes. Claude Code lead can hand work to it: Claude in the cloud plans and reviews, the local model takes summaries, first drafts and files that should stay on the machine. It answers one request at a time and is slower than a cloud model, and the skill never installs one for you. +You can name the coding agent the worker runs with ("a local worker with Pi"), as long as +that agent is installed; the walkthrough is in [Local Models](Local-Models#cloud-and-local-together). With Claude Code workers, where the installed Claude Code supports it, the lead can also message a worker directly instead of typing into its terminal. diff --git a/docs/wiki/Custom-Model-Endpoints.md b/docs/wiki/Custom-Model-Endpoints.md index 4ee5aa596..5ed206aeb 100644 --- a/docs/wiki/Custom-Model-Endpoints.md +++ b/docs/wiki/Custom-Model-Endpoints.md @@ -207,7 +207,9 @@ missing entry is the more current answer. ## A model that runs on this Mac If the Codeman server runs on a Mac with Apple silicon, you do not need a server of your -own: Codeman can set one model up for you on that Mac. This is experimental. +own: Codeman can set one model up for you on that Mac. This is experimental. The walkthrough, +with the coding agents and the cloud plus local setup, is on its own page: +[Local Models](Local-Models). 1. Press **Run a local model** on the welcome screen (or open App Settings > Models > Model on this Mac). @@ -234,6 +236,15 @@ 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**. +- The coding agent is chosen per session. The window lists every agent the model can be run + with (OpenCode, Claude Code, Pi, DeepSeek Harness) under **Start a session with**, and + installs one that is missing after showing you the command. DeepSeek Harness is set up + from the Run menu first. - **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 diff --git a/docs/wiki/Home.md b/docs/wiki/Home.md index 0efad3c4d..f525a1379 100644 --- a/docs/wiki/Home.md +++ b/docs/wiki/Home.md @@ -66,6 +66,7 @@ codeman web # then open http://localhost:3000 | [Tile Grid](Tile-Grid) | How do I watch and drive several sessions side by side? | | [Agent CLIs](Agent-CLIs) | Which agent should this session run, and how do I set it up? | | [Custom Model Endpoints](Custom-Model-Endpoints) | How do I point a session at my own OpenAI-compatible endpoint? | +| [Local Models](Local-Models) | How do I run a coding model on my own Mac, and use it next to a cloud model? | | [Working With Files](Working-With-Files) | How do I read, edit, and attach files? | | [Git Worktrees](Git-Worktrees) | How do several agents work on one repository without sharing a folder? | | [Input And Voice](Input-And-Voice) | How do I talk to an agent, including by voice? | diff --git a/docs/wiki/Local-Models.md b/docs/wiki/Local-Models.md new file mode 100644 index 000000000..c5f4b7436 --- /dev/null +++ b/docs/wiki/Local-Models.md @@ -0,0 +1,115 @@ +# Local Models + +Run a coding model on your own Mac, next to the cloud models you already use. Codeman +installs the model, keeps it running, and lets you start sessions on it or hand it work from +a cloud session. No account and no API key: the model runs only on that Mac. + +This is **experimental**, and it needs a Mac with Apple silicon as the machine the Codeman +server runs on. On a machine that is not a Mac the button stays hidden. + +## Install a model + +1. Press **Run a local model** on the welcome screen (or open App Settings > Models > Model + on this Mac). +2. The window shows which models fit this Mac. The bar at the top is the memory the Mac can + give a model, filled to what the selected model needs at its peak. A nearly full bar is a + tight fit. +3. Pick a model, and under **Runs with** the coding agent to start it with. +4. Press **Install on this Mac**. The next screen lists every download with its size and + where it goes. Nothing is downloaded before you confirm there. +5. Codeman downloads a model runtime and the model into `~/.codeman/local-model`, loads the + model, checks that all of it runs on the GPU and that it can call a tool, and installs + the coding agent if it is missing. No administrator password is asked for. +6. Press **Start a session**. + +You can close the window while it downloads. **Cancel** pauses the install and **Continue** +picks it up where it stopped. After a Codeman restart an unfinished install waits for your +click. + +## Pick the coding agent + +A model is driven by a coding agent: the program that reads your prompt, calls tools and +edits files. You choose it **per session**. Open the window again and look at **Start a +session with**: one chip per agent the model can be run with. + +| Agent | Pick it when | +| ---------------- | ---------------------------------------------------------------------------------------------- | +| OpenCode | You have no reason to choose. It is the pair Codeman was tested with on a Mac | +| Pi | You want the first answer soon. Its fixed prompt is the smallest by far, which also leaves the most room for your work | +| Claude Code | You want Claude Code's own tools and habits. Its first answer on a local model takes minutes | +| DeepSeek Harness | You already use it and have it set up | + +Select a chip and press **Start a session**. The choice is remembered on that device. + +**A chip that says "Not installed"** is an agent Codeman can install for you. Select it: the +window shows the exact command it would run, and the main button becomes **Install** with +the agent's name. The model stays ready while the installer runs, and you can start a +session with another agent meanwhile. If the install fails, the window says so under the +chip and nothing else changes. + +**A chip that says "Needs setup"** is an agent that needs more than one command. DeepSeek +Harness is the one today: set it up from the **Run** menu first, then it can be started +here. A new DeepSeek Harness also opens its own setup guide in every session until you have +finished it once. + +## More than one model + +Once a model is installed, the window lists **Models on this Mac** with what each takes on +disk, and the total under the list. + +- **Add a model** opens the list of models again and downloads another one. When it is + ready it becomes the model in use; the earlier one stays on the Mac. +- **Use** switches to a model that is already there. Nothing is downloaded: Codeman loads it + and checks it again. +- **Delete** removes one model, after one question that says how much space it frees. + +One model runs at a time. Adding and switching are refused while sessions still use the +current model: close those sessions first. The model in use cannot be deleted: switch first, +or use **Remove**, which takes the runtime and the connection away (and the downloaded +models too, if you tick that). + +## Cloud and local together + +The reason to keep a local model next to a cloud one: the cloud model plans and reviews, the +Mac does the bulk. From a Claude Code session in Codeman, with the +[Codeman skill](Codeman-Skill) (on by default), you can just say it: + +| You say | What happens | +| --------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ | +| "Is there a local model on this Mac?" | It reads what is installed, which agents can run it and which models are on the Mac. Read-only | +| "Use the local model to summarize every file in `docs/`, then check its summaries." | A worker on the local model does the reading; the cloud session reads its answer back and reviews it | +| "Spawn a local worker with Pi to draft tests for `src/parser.ts`." | The worker runs the local model through Pi | +| "Start two cloud workers for the API and the UI, and a local one for the changelog." | One mixed fleet: cloud workers side by side, the Mac as a queue | + +The worker sessions appear as tabs in the dashboard and are closed when the work is done. + +Good to know before you rely on it: + +- **One request at a time.** The local model answers one request at a time, so two local + workers take turns. Fan out to the cloud, and give the Mac a queue. +- **Slower than a cloud model**, and the first request after a start loads the model first. +- **Review what comes back.** A local model is good at bulk work and weaker at judgment. + Treat its answer as a first draft. +- **The skill never installs anything.** A model or an agent that is missing is yours to + install in the window; the skill tells you what is missing and stops. +- **While a local worker is open**, the window refuses Stop, Remove and a switch of models, + and names the sessions that are in the way. + +## Good to know + +- **What was tested.** One pair was run on a real Mac: OpenCode with Qwen3.6 35B-A3B on an + M5 Pro with 48 GB. The window marks that pair on a Mac like it. Everything else in the + list is calculated from each model's own figures, and the install checks the model on your + Mac before it lets you start a session. +- **Memory first.** A model that does not fit in memory is not offered. An estimate becomes + a measurement the first time the runtime starts, and the model is judged against that + before it is downloaded. +- **The developer tools.** Coding agents use git and Python. On a Mac without them, macOS + offers to install them the first time an agent needs one. +- **Stop** keeps the runtime from starting with Codeman next time. **Start** undoes that. +- **In multi-user mode** only an admin sees and uses this. + +More detail: [A model that runs on this Mac](Custom-Model-Endpoints#a-model-that-runs-on-this-mac) +for how it relates to your own model servers, and +[`docs/local-models.md`](https://github.com/Ark0N/Codeman/blob/master/docs/local-models.md) +for how it works inside. diff --git a/docs/wiki/_Sidebar.md b/docs/wiki/_Sidebar.md index 36a049093..dc1ebeb45 100644 --- a/docs/wiki/_Sidebar.md +++ b/docs/wiki/_Sidebar.md @@ -14,6 +14,7 @@ - [Tile Grid](Tile-Grid) - [Agent CLIs](Agent-CLIs) - [Custom Model Endpoints](Custom-Model-Endpoints) +- [Local Models](Local-Models) - [Working With Files](Working-With-Files) - [Git Worktrees](Git-Worktrees) - [Input And Voice](Input-And-Voice) diff --git a/plugins/codeman/skills/codeman/SKILL.md b/plugins/codeman/skills/codeman/SKILL.md index 3c4c9b67e..75071367c 100644 --- a/plugins/codeman/skills/codeman/SKILL.md +++ b/plugins/codeman/skills/codeman/SKILL.md @@ -107,7 +107,7 @@ later call opens with, and your first REAL call performs them anyway: ```bash . "${XDG_CACHE_HOME:-$HOME/.cache}/codeman-agent-$CODEMAN_SESSION_ID.sh" 2>/dev/null -[ "${CODEMAN_PREAMBLE:-}" = 1.50.0 ] || { echo "preamble missing or stale; run the full §0 block"; exit 1; } +[ "${CODEMAN_PREAMBLE:-}" = 1.51.0 ] || { echo "preamble missing or stale; run the full §0 block"; exit 1; } ``` ⚠️ **Never spend a Bash call on this check alone.** §1's block opens with this same @@ -135,8 +135,8 @@ PRE="${XDG_CACHE_HOME:-$HOME/.cache}/codeman-agent-$CODEMAN_SESSION_ID.sh" mkdir -p "$(dirname "$PRE")" # Rewrite unless the file already ends with THIS version's stamp, so a stale or a # half-written file self-heals here instead of costing you a round trip to rm it. -grep -qs '^CODEMAN_PREAMBLE=1.50.0$' "$PRE" || (umask 077; cat > "$PRE" <<'PREAMBLE' -# ---- Codeman agent preamble 1.50.0 (seeded by Codeman at session spawn; the SKILL.md §0 bootstrap rewrites it when missing or stale) ---- +grep -qs '^CODEMAN_PREAMBLE=1.51.0$' "$PRE" || (umask 077; cat > "$PRE" <<'PREAMBLE' +# ---- Codeman agent preamble 1.51.0 (seeded by Codeman at session spawn; the SKILL.md §0 bootstrap rewrites it when missing or stale) ---- # Positional parameters are always written with braces, ${1} and never a bare dollar-digit: Claude Code # replaces a bare one in a skill body with the matching word of the skill's arguments, and §0 is pasted # from that body (test/agent-skill.test.ts pins it). @@ -197,6 +197,17 @@ _dsh_up() { # -> "true"/"false". The DeepSeek Harness T # composer glyph. Override with DSH_READY_MARK for a profile that draws another one. _mark_up "${1}" "${DSH_READY_MARK:-❯}" "${2}" } +_dsh_guide() { # -> 0 when the pane shows dsh-TUI's first-run SETUP GUIDE, not a composer. + # dsh-TUI 0.19.0 opens a guide ("Welcome to dsh-TUI Step 1 of 4") in every session until a + # person has finished it once, and the guide's own cursor is `❯`: the readiness mark matches + # on it (measured, 2 of 2 launches), and an Enter there acts on the highlighted step, which + # is a sign-in. So a worker whose mark matched is still checked for the guide, on the + # RENDERED pane with its whitespace removed, and never handed back while it shows. + "${CURL[@]}" -G "$API/api/v1/sessions/${1}/terminal" --data-urlencode 'full=1' \ + | jq -r '.data.terminalBuffer // empty' \ + | sed -e "s/$(printf '\033')\[[0-9;?]*[a-zA-Z]//g" -e "s/$(printf '\033')[()][AB0]//g" \ + | tr -d ' \t' | grep -qi 'Step[0-9]of[0-9]' +} # ---- the workspace-trust dialog: READ the screen, never press Enter blind ---- # Claude Code 2.1.252 dropped the option numbers, REVERSED them, and highlights # "No, exit" by default: @@ -259,9 +270,18 @@ _accept_trust() { # -> 0 once it has answered the dialog, 1 if it could n # local_model -> ONE compact JSON object on stdout. rc 0 only when a `local` worker can be # spawned now (`running:true`) or once spawn_worker has started the runtime (`running:false`): # {"spawnable":true,"state":"installed","running":true,"cli":"opencode","cliLabel":"OpenCode", -# "endpointId":"local-ollama","modelId":"","modelLabel":"